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
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-24 08:38 +0000
1"""SOPSy, a Python wrapper around SOPS."""
3from __future__ import annotations
5import shutil
6import tempfile
7from enum import Enum
8from pathlib import Path
9from typing import Any
11import yaml
13from sopsy.errors import SopsyCommandNotFoundError
14from sopsy.utils import build_config
15from sopsy.utils import run_cmd
18class SopsyInOutType(Enum):
19 """SOPS output types.
21 Intend to be passed on `Sops().input_type` and `Sops().output_type`.
23 Attributes:
24 BINARY (str): Binary type.
25 DOTENV (str): DotEnv type.
26 JSON (str): JSON type.
27 YAML (str): YAML type.
28 """
30 BINARY = "binary"
31 DOTENV = "dotenv"
32 JSON = "json"
33 YAML = "yaml"
35 def __str__(self) -> str:
36 """Return the string used for str() calls."""
37 return f"{self.value}"
40class Sops:
41 """SOPS file object.
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 """
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.
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 >>> )
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)])
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])
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)
122 def decrypt(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None:
123 """Decrypt SOPS file.
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
131 Args:
132 to_dict: Return the output as a Python dict.
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)
140 def encrypt(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None:
141 """Encrypt SOPS file.
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()
152 Args:
153 to_dict: Return the output as a Python dict.
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)
161 def get(self, key: str, *, default: Any = None) -> Any: # noqa: ANN401
162 """Get a specific key from a SOPS encrypted file.
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'
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.
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
182 def rotate(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None:
183 """Rotate encryption keys and re-encrypt values from SOPS file.
185 Examples:
186 >>> from sopsy import Sops
187 >>> sops = Sops("secrets.json", in_place=True)
188 >>> sops.rotate()
190 Args:
191 to_dict: Return the output as a Python dict.
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)