Coverage for src/sopsy/sopsy.py: 100%

58 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-11-24 08:38 +0000

1"""SOPSy, a Python wrapper around SOPS.""" 

2 

3from __future__ import annotations 

4 

5import shutil 

6import tempfile 

7from enum import Enum 

8from pathlib import Path 

9from typing import Any 

10 

11import yaml 

12 

13from sopsy.errors import SopsyCommandNotFoundError 

14from sopsy.utils import build_config 

15from sopsy.utils import run_cmd 

16 

17 

18class SopsyInOutType(Enum): 

19 """SOPS output types. 

20 

21 Intend to be passed on `Sops().input_type` and `Sops().output_type`. 

22 

23 Attributes: 

24 BINARY (str): Binary type. 

25 DOTENV (str): DotEnv type. 

26 JSON (str): JSON type. 

27 YAML (str): YAML type. 

28 """ 

29 

30 BINARY = "binary" 

31 DOTENV = "dotenv" 

32 JSON = "json" 

33 YAML = "yaml" 

34 

35 def __str__(self) -> str: 

36 """Return the string used for str() calls.""" 

37 return f"{self.value}" 

38 

39 

40class Sops: 

41 """SOPS file object. 

42 

43 Attributes: 

44 file: Path to the SOPS file. 

45 global_args: The list of arguments that will be passed to the `sops` shell 

46 command. It can be used to customize it. Use it only if you know what you 

47 are doing. 

48 """ 

49 

50 def __init__( 

51 self, 

52 file: str | Path, 

53 *, 

54 binary_path: str | Path | None = None, 

55 config: str | Path | None = None, 

56 config_dict: dict[str, Any] | None = None, 

57 extract: str | None = None, 

58 in_place: bool = False, 

59 input_type: str | SopsyInOutType | None = None, 

60 output: str | Path | None = None, 

61 output_type: str | SopsyInOutType | None = None, 

62 ) -> None: 

63 """Initialize SOPS object. 

64 

65 Examples: 

66 >>> from pathlib import Path 

67 >>> from sopsy import Sops 

68 >>> sops = Sops( 

69 >>> binary_path="/app/bin/my_custom_sops", 

70 >>> config=Path(".config/sops.yml"), 

71 >>> file=Path("secrets.json"), 

72 >>> ) 

73 

74 Args: 

75 file: Path to the SOPS file. 

76 config: Path to a custom SOPS config file. 

77 config_dict: Allow to pass SOPS config as a python dict. 

78 extract: Extract a specific key or branch from the input document. 

79 in_place: Write output back to the same file instead of stdout. 

80 input_type: If not set, sops will use the file's extension to determine 

81 the type. 

82 output: Save the output after encryption or decryption to the file 

83 specified. 

84 output_type: If not set, sops will use the input file's extension to 

85 determine the output format. 

86 binary_path: Path to the SOPS binary. If not defined it will search for it 

87 in the PATH environment variable. 

88 """ 

89 self.bin: Path = Path("sops") 

90 self.file: Path = Path(file).resolve(strict=True) 

91 self.global_args: list[str] = [] 

92 if binary_path: 

93 self.bin = Path(binary_path) 

94 if extract: 

95 self.global_args.extend(["--extract", extract]) 

96 if in_place: 

97 self.global_args.extend(["--in-place"]) 

98 if input_type: 

99 self.global_args.extend(["--input-type", str(input_type)]) 

100 if output: 

101 self.global_args.extend(["--output", str(output)]) 

102 if output_type: 

103 self.global_args.extend(["--output-type", str(output_type)]) 

104 

105 if isinstance(config, str): 

106 config = Path(config) 

107 if config_dict is None: 

108 config_dict = {} 

109 config_dict = build_config(config_path=config, config_dict=config_dict) 

110 with tempfile.NamedTemporaryFile(mode="w", delete=False) as fp: 

111 yaml.dump(config_dict, fp) 

112 config_tmp = fp.name 

113 self.global_args.extend(["--config", config_tmp]) 

114 

115 if not shutil.which(self.bin): 

116 msg = ( 

117 f"{self.bin} command not found, " 

118 "you may need to install it and/or add it to your PATH" 

119 ) 

120 raise SopsyCommandNotFoundError(msg) 

121 

122 def decrypt(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None: 

123 """Decrypt SOPS file. 

124 

125 Examples: 

126 >>> from sopsy import Sops, SopsyInOutType 

127 >>> sops = Sops("secrets.json", output_type=SopsyInOutType.YAML) 

128 >>> sops.decrypt(to_dict=False) 

129 hello: world 

130 

131 Args: 

132 to_dict: Return the output as a Python dict. 

133 

134 Returns: 

135 The output of the sops command. 

136 """ 

137 cmd = [str(self.bin), "--decrypt", *self.global_args, str(self.file)] 

138 return run_cmd(cmd, to_dict=to_dict) 

139 

140 def encrypt(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None: 

141 """Encrypt SOPS file. 

142 

143 Examples: 

144 >>> import json 

145 >>> from pathlib import Path 

146 >>> from sopsy import Sops 

147 >>> secrets = Path("secrets.json") 

148 >>> secrets.write_text(json.dumps({"hello": "world"})) 

149 >>> sops = Sops(secrets, in_place=True) 

150 >>> sops.encrypt() 

151 

152 Args: 

153 to_dict: Return the output as a Python dict. 

154 

155 Returns: 

156 The output of the sops command. 

157 """ 

158 cmd = [str(self.bin), "--encrypt", *self.global_args, str(self.file)] 

159 return run_cmd(cmd, to_dict=to_dict) 

160 

161 def get(self, key: str, *, default: Any = None) -> Any: # noqa: ANN401 

162 """Get a specific key from a SOPS encrypted file. 

163 

164 Examples: 

165 >>> from sopsy import Sops 

166 >>> sops = Sops("secrets.json") 

167 >>> sops.get("hello") 

168 b'world' 

169 >>> sops.get("nonexistent", default="DefaultValue") 

170 'DefaultValue' 

171 

172 Args: 

173 key: The key to fetch in the SOPS file content. 

174 default: A default value in case the key does not exist or is empty. 

175 

176 Returns: 

177 The value of the given key, or the default value. 

178 """ 

179 data: dict[Any, Any] = self.decrypt() # pyright: ignore[reportAssignmentType] 

180 return data.get(key) or default 

181 

182 def rotate(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None: 

183 """Rotate encryption keys and re-encrypt values from SOPS file. 

184 

185 Examples: 

186 >>> from sopsy import Sops 

187 >>> sops = Sops("secrets.json", in_place=True) 

188 >>> sops.rotate() 

189 

190 Args: 

191 to_dict: Return the output as a Python dict. 

192 

193 Returns: 

194 The output of the sops command. 

195 """ 

196 cmd = [str(self.bin), "--rotate", *self.global_args, str(self.file)] 

197 return run_cmd(cmd, to_dict=to_dict)