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

55 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-23 04:59 +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 config: str | Path | None = None, 

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

56 extract: str | None = None, 

57 in_place: bool = False, 

58 input_type: str | SopsyInOutType | None = None, 

59 output: str | Path | None = None, 

60 output_type: str | SopsyInOutType | None = None, 

61 ) -> None: 

62 """Initialize SOPS object. 

63 

64 Examples: 

65 >>> from pathlib import Path 

66 >>> from sopsy import Sops 

67 >>> sops = Sops(Path("secrets.json"), config=Path(".config/sops.yml")) 

68 

69 Args: 

70 file: Path to the SOPS file. 

71 config: Path to a custom SOPS config file. 

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

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

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

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

76 the type. 

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

78 specified. 

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

80 determine the output format. 

81 """ 

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

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

84 if extract: 

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

86 if in_place: 

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

88 if input_type: 

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

90 if output: 

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

92 if output_type: 

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

94 

95 if isinstance(config, str): 

96 config = Path(config) 

97 if config_dict is None: 

98 config_dict = {} 

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

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

101 yaml.dump(config_dict, fp) 

102 config_tmp = fp.name 

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

104 

105 if not shutil.which("sops"): 

106 msg = ( 

107 "sops command not found, " 

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

109 ) 

110 raise SopsyCommandNotFoundError(msg) 

111 

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

113 """Decrypt SOPS file. 

114 

115 Examples: 

116 >>> from sopsy import Sops, SopsyInOutType 

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

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

119 hello: world 

120 

121 Args: 

122 to_dict: Return the output as a Python dict. 

123 

124 Returns: 

125 The output of the sops command. 

126 """ 

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

128 return run_cmd(cmd, to_dict=to_dict) 

129 

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

131 """Encrypt SOPS file. 

132 

133 Examples: 

134 >>> import json 

135 >>> from pathlib import Path 

136 >>> from sopsy import Sops 

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

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

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

140 >>> sops.encrypt() 

141 

142 Args: 

143 to_dict: Return the output as a Python dict. 

144 

145 Returns: 

146 The output of the sops command. 

147 """ 

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

149 return run_cmd(cmd, to_dict=to_dict) 

150 

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

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

153 

154 Examples: 

155 >>> from sopsy import Sops 

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

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

158 b'world' 

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

160 'DefaultValue' 

161 

162 Args: 

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

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

165 

166 Returns: 

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

168 """ 

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

170 return data.get(key) or default 

171 

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

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

174 

175 Examples: 

176 >>> from sopsy import Sops 

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

178 >>> sops.rotate() 

179 

180 Args: 

181 to_dict: Return the output as a Python dict. 

182 

183 Returns: 

184 The output of the sops command. 

185 """ 

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

187 return run_cmd(cmd, to_dict=to_dict)