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
« prev ^ index » next coverage.py v7.6.1, created at 2024-09-23 04:59 +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 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.
64 Examples:
65 >>> from pathlib import Path
66 >>> from sopsy import Sops
67 >>> sops = Sops(Path("secrets.json"), config=Path(".config/sops.yml"))
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)])
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])
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)
112 def decrypt(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None:
113 """Decrypt SOPS file.
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
121 Args:
122 to_dict: Return the output as a Python dict.
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)
130 def encrypt(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None:
131 """Encrypt SOPS file.
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()
142 Args:
143 to_dict: Return the output as a Python dict.
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)
151 def get(self, key: str, *, default: Any = None) -> Any: # noqa: ANN401
152 """Get a specific key from a SOPS encrypted file.
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'
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.
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
172 def rotate(self, *, to_dict: bool = True) -> bytes | dict[str, Any] | None:
173 """Rotate encryption keys and re-encrypt values from SOPS file.
175 Examples:
176 >>> from sopsy import Sops
177 >>> sops = Sops("secrets.json", in_place=True)
178 >>> sops.rotate()
180 Args:
181 to_dict: Return the output as a Python dict.
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)