omerta 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omerta/__init__.py +5 -0
- omerta/check.py +125 -0
- omerta/cli.py +214 -0
- omerta/config.py +120 -0
- omerta/envfiles.py +153 -0
- omerta/errors.py +11 -0
- omerta/forms.py +98 -0
- omerta/git.py +299 -0
- omerta/history.py +236 -0
- omerta/output.py +147 -0
- omerta/patterns.py +74 -0
- omerta/py.typed +0 -0
- omerta/search.py +186 -0
- omerta-0.1.0.dist-info/METADATA +62 -0
- omerta-0.1.0.dist-info/RECORD +18 -0
- omerta-0.1.0.dist-info/WHEEL +4 -0
- omerta-0.1.0.dist-info/entry_points.txt +3 -0
- omerta-0.1.0.dist-info/licenses/LICENSE +21 -0
omerta/__init__.py
ADDED
omerta/check.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""``omerta check``: the index content of the staged changes, of given paths, or of every entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from omerta.errors import OmertaError
|
|
8
|
+
from omerta.git import GITLINK, Entry, Git
|
|
9
|
+
from omerta.output import Finding
|
|
10
|
+
from omerta.patterns import Pattern, is_env_file
|
|
11
|
+
from omerta.search import Matcher, Target, line_of
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def select(git: Git, index: list[Entry], paths: list[str], everything: bool) -> list[Entry]:
|
|
15
|
+
"""The index entries that ``check`` searches.
|
|
16
|
+
|
|
17
|
+
A PATH selects the entry at that path, or with a folder the entries under
|
|
18
|
+
it, matched exactly, as literal pathspecs are. The paths are matched here,
|
|
19
|
+
not given to git, so that none goes on git's command line.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
git: git, in the repository.
|
|
23
|
+
index: Every index entry.
|
|
24
|
+
paths: The PATH arguments, relative to the folder omerta runs in.
|
|
25
|
+
everything: ``--all``: every entry.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
OmertaError: A path is outside the repository.
|
|
29
|
+
"""
|
|
30
|
+
if everything:
|
|
31
|
+
return index
|
|
32
|
+
if paths:
|
|
33
|
+
selected = [_from_root(git, path) for path in paths]
|
|
34
|
+
return [
|
|
35
|
+
entry
|
|
36
|
+
for entry in index
|
|
37
|
+
if any(
|
|
38
|
+
not chosen or entry.path == chosen or entry.path.startswith(chosen + b"/")
|
|
39
|
+
for chosen in selected
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
staged = git.staged_paths()
|
|
43
|
+
return [entry for entry in index if entry.path in staged]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _from_root(git: Git, path: str) -> bytes:
|
|
47
|
+
"""A PATH argument as an index path: from the root, with ``/``; empty for the root."""
|
|
48
|
+
full = os.path.abspath(os.path.join(git.folder, path))
|
|
49
|
+
try:
|
|
50
|
+
relative = os.path.relpath(full, os.path.abspath(git.top))
|
|
51
|
+
except ValueError: # on another drive
|
|
52
|
+
relative = os.pardir
|
|
53
|
+
if relative == os.pardir or relative.startswith(os.pardir + os.sep):
|
|
54
|
+
raise OmertaError(f"{path} is outside the repository")
|
|
55
|
+
return b"" if relative == os.curdir else os.fsencode(relative.replace(os.sep, "/"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(
|
|
59
|
+
git: Git,
|
|
60
|
+
entries: list[Entry],
|
|
61
|
+
targets: list[Target],
|
|
62
|
+
patterns: tuple[Pattern, ...],
|
|
63
|
+
findings: list[Finding],
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Search the entries, adding what is found to ``findings``.
|
|
66
|
+
|
|
67
|
+
An entry that matches the patterns is a tracked env file, and a path that
|
|
68
|
+
holds a value is a finding of its own. A gitlink (a submodule) has no
|
|
69
|
+
content to search. An unmerged entry is searched in each of its stages,
|
|
70
|
+
and a value is reported at its first occurrence in the lowest stage that
|
|
71
|
+
holds it.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
OmertaError: An entry's object cannot be read.
|
|
75
|
+
"""
|
|
76
|
+
matcher = Matcher(targets)
|
|
77
|
+
for path in sorted({entry.path for entry in entries}):
|
|
78
|
+
for index, hit in matcher.search(path).items():
|
|
79
|
+
findings.append(_found(targets[index], hit.form, path=path, in_path=True))
|
|
80
|
+
entries = sorted(
|
|
81
|
+
(entry for entry in entries if entry.mode != GITLINK),
|
|
82
|
+
key=lambda entry: (entry.path, entry.stage),
|
|
83
|
+
)
|
|
84
|
+
tracked: set[bytes] = set()
|
|
85
|
+
for entry in entries:
|
|
86
|
+
if entry.path not in tracked and is_env_file(entry.path, patterns):
|
|
87
|
+
tracked.add(entry.path)
|
|
88
|
+
findings.append(Finding(path=entry.path))
|
|
89
|
+
if not targets:
|
|
90
|
+
return
|
|
91
|
+
by_oid: dict[str, list[Entry]] = {}
|
|
92
|
+
for entry in entries:
|
|
93
|
+
by_oid.setdefault(entry.oid, []).append(entry)
|
|
94
|
+
found: dict[tuple[bytes, int], tuple[int, Finding]] = {}
|
|
95
|
+
try:
|
|
96
|
+
for oid, _, data in git.objects(list(by_oid)):
|
|
97
|
+
hits = matcher.search(data)
|
|
98
|
+
for entry in by_oid[oid]:
|
|
99
|
+
for index, hit in hits.items():
|
|
100
|
+
key = (entry.path, index)
|
|
101
|
+
if key in found and found[key][0] <= entry.stage:
|
|
102
|
+
continue
|
|
103
|
+
line = line_of(data, hit.position)
|
|
104
|
+
found[key] = (
|
|
105
|
+
entry.stage,
|
|
106
|
+
_found(targets[index], hit.form, path=entry.path, line=line),
|
|
107
|
+
)
|
|
108
|
+
finally:
|
|
109
|
+
# What was found before an error is reported with it.
|
|
110
|
+
findings.extend(finding for _, finding in found.values())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _found(
|
|
114
|
+
target: Target, form: str, *, path: bytes, line: int | None = None, in_path: bool = False
|
|
115
|
+
) -> Finding:
|
|
116
|
+
"""A finding of a target in an entry: in its content at a line, or in its path."""
|
|
117
|
+
return Finding(
|
|
118
|
+
path=path,
|
|
119
|
+
line=line,
|
|
120
|
+
in_path=in_path,
|
|
121
|
+
variable=target.variable,
|
|
122
|
+
env_file=target.env_file,
|
|
123
|
+
env_order=target.env_order,
|
|
124
|
+
form=form,
|
|
125
|
+
)
|
omerta/cli.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The command line: ``omerta check`` and ``omerta history``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from omerta import __version__, check, config, envfiles, history
|
|
14
|
+
from omerta.errors import OmertaError
|
|
15
|
+
from omerta.git import WINDOWS, Git, GitError, environment, find_executable, find_repository
|
|
16
|
+
from omerta.output import Finding, json_line, sort_key, text_line
|
|
17
|
+
from omerta.search import Target, redact, targets
|
|
18
|
+
|
|
19
|
+
_ISSUES = "https://github.com/shcherbak-ai/omerta/issues"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
"""Run omerta with the arguments of the command line, or ``argv``.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The exit code: 0 when nothing is found, 1 when a value or a tracked env
|
|
27
|
+
file is found, and 2 on an error, which wins over findings.
|
|
28
|
+
"""
|
|
29
|
+
parser, check_parser = _parser()
|
|
30
|
+
try:
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
if args.command == "check" and args.all and args.paths:
|
|
33
|
+
check_parser.error("PATH arguments cannot be given with --all")
|
|
34
|
+
except SystemExit as exit_:
|
|
35
|
+
return exit_.code if isinstance(exit_.code, int) else 0 if exit_.code is None else 2
|
|
36
|
+
findings: list[Finding] = []
|
|
37
|
+
searched: list[Target] = []
|
|
38
|
+
|
|
39
|
+
def hide(data: bytes) -> bytes:
|
|
40
|
+
"""``data`` with each value of the env files replaced by its variable's name."""
|
|
41
|
+
return redact(data, searched)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
_run(args, findings, searched)
|
|
45
|
+
error = None
|
|
46
|
+
except GitError as failure:
|
|
47
|
+
error = failure.describe(hide)
|
|
48
|
+
except OmertaError as failure:
|
|
49
|
+
error = str(failure)
|
|
50
|
+
except Exception as failure: # noqa: BLE001 - reported without its text, which may hold a value
|
|
51
|
+
frame = traceback.extract_tb(failure.__traceback__)[-1]
|
|
52
|
+
error = (
|
|
53
|
+
f"unexpected {type(failure).__name__} at {Path(frame.filename).name}:{frame.lineno}; "
|
|
54
|
+
f"please report it at {_ISSUES}"
|
|
55
|
+
)
|
|
56
|
+
if not args.quiet:
|
|
57
|
+
line = json_line if args.json else text_line
|
|
58
|
+
ordered = sorted(findings, key=sort_key)
|
|
59
|
+
_write(sys.stdout, "".join(line(each, hide) + "\n" for each in ordered))
|
|
60
|
+
if error is not None:
|
|
61
|
+
# A path or an env file's name in the message may hold a value too.
|
|
62
|
+
message = hide(f"omerta: {error}\n".encode("utf-8", "surrogateescape"))
|
|
63
|
+
_write(sys.stderr, message.decode("utf-8", "backslashreplace"))
|
|
64
|
+
return 2
|
|
65
|
+
return 1 if findings else 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run(args: argparse.Namespace, findings: list[Finding], searched: list[Target]) -> None:
|
|
69
|
+
"""Run a command, adding what it finds to ``findings``, and its targets to ``searched``."""
|
|
70
|
+
start = os.getcwd()
|
|
71
|
+
folder = start
|
|
72
|
+
for given in args.folders:
|
|
73
|
+
folder = os.path.join(folder, given)
|
|
74
|
+
if not os.path.isdir(folder):
|
|
75
|
+
raise OmertaError(f"cannot change to {given}: no such folder")
|
|
76
|
+
# Where its links lead, as git sees it: git's `--show-cdup` climbs from there.
|
|
77
|
+
folder = os.path.realpath(folder)
|
|
78
|
+
executable = find_executable(
|
|
79
|
+
os.environ.get("PATH", ""), [start, folder], find_repository(folder)
|
|
80
|
+
)
|
|
81
|
+
git = Git(executable, folder, environment())
|
|
82
|
+
git.open()
|
|
83
|
+
if args.command == "history" and git.is_shallow():
|
|
84
|
+
raise OmertaError(
|
|
85
|
+
"this repository is a shallow clone, whose history stops at a cut: fetch all of it "
|
|
86
|
+
"(in CI, check out with fetch-depth: 0)"
|
|
87
|
+
)
|
|
88
|
+
top = Path(git.top)
|
|
89
|
+
settings = config.load(
|
|
90
|
+
top,
|
|
91
|
+
env_files=args.env_files,
|
|
92
|
+
min_chars=args.min_chars,
|
|
93
|
+
ignore=args.ignore,
|
|
94
|
+
encoded=args.encoded,
|
|
95
|
+
)
|
|
96
|
+
index = git.index()
|
|
97
|
+
sources = envfiles.find(top, settings.patterns, {entry.path for entry in index})
|
|
98
|
+
searched.extend(targets(sources, settings))
|
|
99
|
+
if args.command == "check":
|
|
100
|
+
entries = check.select(git, index, args.paths, args.all)
|
|
101
|
+
check.run(git, entries, searched, settings.patterns, findings)
|
|
102
|
+
else:
|
|
103
|
+
history.run(git, searched, settings.patterns, findings)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parser() -> tuple[argparse.ArgumentParser, argparse.ArgumentParser]:
|
|
107
|
+
"""The parser of the command line, and that of ``check``."""
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
prog="omerta",
|
|
110
|
+
description="Fail when a value of your env files, e.g. an API key in .env, is in git.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"-C",
|
|
114
|
+
dest="folders",
|
|
115
|
+
action="append",
|
|
116
|
+
default=[],
|
|
117
|
+
metavar="PATH",
|
|
118
|
+
help="run as if started in PATH, as git -C does",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument("--version", action="version", version=f"omerta {__version__}")
|
|
121
|
+
commands = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
|
|
122
|
+
options = argparse.ArgumentParser(add_help=False)
|
|
123
|
+
options.add_argument(
|
|
124
|
+
"--min-chars",
|
|
125
|
+
type=_positive,
|
|
126
|
+
metavar="N",
|
|
127
|
+
help="the shortest value searched for, in characters (default: 8)",
|
|
128
|
+
)
|
|
129
|
+
options.add_argument(
|
|
130
|
+
"--env-file",
|
|
131
|
+
dest="env_files",
|
|
132
|
+
action="append",
|
|
133
|
+
metavar="PATTERN",
|
|
134
|
+
help="an env file pattern, repeatable (default: .env and .env.*)",
|
|
135
|
+
)
|
|
136
|
+
options.add_argument(
|
|
137
|
+
"--ignore",
|
|
138
|
+
action="append",
|
|
139
|
+
default=[],
|
|
140
|
+
metavar="NAME",
|
|
141
|
+
help="a variable not searched for, repeatable",
|
|
142
|
+
)
|
|
143
|
+
options.add_argument(
|
|
144
|
+
"--encoded",
|
|
145
|
+
action=argparse.BooleanOptionalAction,
|
|
146
|
+
help="search the URL, JSON and base64 forms too (default), or the plain forms only",
|
|
147
|
+
)
|
|
148
|
+
options.add_argument("--json", action="store_true", help="findings as JSON Lines")
|
|
149
|
+
options.add_argument(
|
|
150
|
+
"-q", "--quiet", action="store_true", help="print no findings: the exit code says it"
|
|
151
|
+
)
|
|
152
|
+
found = commands.add_parser(
|
|
153
|
+
"check",
|
|
154
|
+
parents=[options],
|
|
155
|
+
help="search the staged changes, PATHs, or every index entry",
|
|
156
|
+
description=(
|
|
157
|
+
"Search the index content of the staged changes, of the PATHs given, or with --all "
|
|
158
|
+
"of every entry."
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
found.add_argument("paths", nargs="*", metavar="PATH", help="paths of index entries")
|
|
162
|
+
found.add_argument("--all", action="store_true", help="search every index entry")
|
|
163
|
+
commands.add_parser(
|
|
164
|
+
"history",
|
|
165
|
+
parents=[options],
|
|
166
|
+
help="search every blob and message reachable from a ref",
|
|
167
|
+
description=(
|
|
168
|
+
"Search every blob reachable from a ref, and the messages of the commits and tags."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
return parser, found
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _positive(text: str) -> int:
|
|
175
|
+
"""An integer of at least 1, from the command line."""
|
|
176
|
+
try:
|
|
177
|
+
number = int(text)
|
|
178
|
+
except ValueError:
|
|
179
|
+
number = 0
|
|
180
|
+
if number < 1:
|
|
181
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not an integer of at least 1")
|
|
182
|
+
return number
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _write(stream: TextIO, text: str) -> None:
|
|
186
|
+
"""Write text to a stream in UTF-8, with ``\\n`` line ends, whatever its encoding.
|
|
187
|
+
|
|
188
|
+
A stream whose reader has gone (``omerta history | head``) takes nothing
|
|
189
|
+
more: the rest is dropped, and so is what Python would flush into it at exit.
|
|
190
|
+
"""
|
|
191
|
+
buffer = getattr(stream, "buffer", None)
|
|
192
|
+
try:
|
|
193
|
+
if buffer is None:
|
|
194
|
+
stream.write(text)
|
|
195
|
+
return
|
|
196
|
+
stream.flush()
|
|
197
|
+
buffer.write(text.encode("utf-8"))
|
|
198
|
+
buffer.flush()
|
|
199
|
+
except OSError as error:
|
|
200
|
+
if not _reader_gone(error):
|
|
201
|
+
raise
|
|
202
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
203
|
+
os.dup2(devnull, stream.fileno())
|
|
204
|
+
os.close(devnull)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _reader_gone(error: OSError) -> bool:
|
|
208
|
+
"""Whether a write failed because the reader of its pipe has gone.
|
|
209
|
+
|
|
210
|
+
Other platforms raise :class:`BrokenPipeError`. Windows raises it too
|
|
211
|
+
for a reader that goes while omerta writes, but ``EINVAL`` for one that
|
|
212
|
+
went before the write.
|
|
213
|
+
"""
|
|
214
|
+
return isinstance(error, BrokenPipeError) or (WINDOWS and error.errno == errno.EINVAL)
|
omerta/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The settings of a run: the defaults, the configuration file, and the options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from omerta.errors import OmertaError
|
|
11
|
+
from omerta.patterns import DEFAULT_PATTERNS, Pattern
|
|
12
|
+
|
|
13
|
+
DEFAULT_MIN_CHARS = 8
|
|
14
|
+
|
|
15
|
+
_KEYS = ("env-files", "min-chars", "ignore", "encoded")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Settings:
|
|
20
|
+
"""What a run searches for: the env files, and which of their values."""
|
|
21
|
+
|
|
22
|
+
patterns: tuple[Pattern, ...]
|
|
23
|
+
min_chars: int
|
|
24
|
+
ignore: frozenset[str]
|
|
25
|
+
encoded: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(
|
|
29
|
+
top: Path,
|
|
30
|
+
*,
|
|
31
|
+
env_files: list[str] | None = None,
|
|
32
|
+
min_chars: int | None = None,
|
|
33
|
+
ignore: list[str] | None = None,
|
|
34
|
+
encoded: bool | None = None,
|
|
35
|
+
) -> Settings:
|
|
36
|
+
"""The settings, from the options given, the configuration, and the defaults.
|
|
37
|
+
|
|
38
|
+
An option overrides the setting of the same name in the configuration,
|
|
39
|
+
which overrides the default, except ``ignore``, whose names are added to
|
|
40
|
+
the configured ones.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
top: The root of the repository, where the configuration file is.
|
|
44
|
+
env_files: The env file patterns of the command line, if any.
|
|
45
|
+
min_chars: The ``--min-chars`` of the command line, if given.
|
|
46
|
+
ignore: The variable names of the command line.
|
|
47
|
+
encoded: ``--encoded`` or ``--no-encoded``, if given.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
OmertaError: The configuration cannot be read or has a wrong setting.
|
|
51
|
+
"""
|
|
52
|
+
configured = _read(top)
|
|
53
|
+
patterns = env_files or configured.get("env-files") or list(DEFAULT_PATTERNS)
|
|
54
|
+
return Settings(
|
|
55
|
+
patterns=tuple(Pattern(text) for text in patterns),
|
|
56
|
+
min_chars=min_chars or configured.get("min-chars", DEFAULT_MIN_CHARS),
|
|
57
|
+
ignore=frozenset([*configured.get("ignore", []), *(ignore or [])]),
|
|
58
|
+
encoded=configured.get("encoded", True) if encoded is None else encoded,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read(top: Path) -> dict[str, Any]:
|
|
63
|
+
"""The settings of ``[tool.omerta]`` in ``pyproject.toml`` or of ``.omerta.toml``."""
|
|
64
|
+
pyproject = _toml(top / "pyproject.toml")
|
|
65
|
+
tool = pyproject.get("tool") if pyproject is not None else None
|
|
66
|
+
in_pyproject = tool.get("omerta") if isinstance(tool, dict) else None
|
|
67
|
+
standalone = _toml(top / ".omerta.toml")
|
|
68
|
+
if in_pyproject is not None and standalone is not None:
|
|
69
|
+
raise OmertaError(
|
|
70
|
+
"settings are in both pyproject.toml ([tool.omerta]) and .omerta.toml: keep one"
|
|
71
|
+
)
|
|
72
|
+
if in_pyproject is not None:
|
|
73
|
+
if not isinstance(in_pyproject, dict):
|
|
74
|
+
raise OmertaError("pyproject.toml: tool.omerta must be a table")
|
|
75
|
+
return _validate(in_pyproject, "pyproject.toml [tool.omerta]")
|
|
76
|
+
if standalone is not None:
|
|
77
|
+
return _validate(standalone, ".omerta.toml")
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _toml(path: Path) -> dict[str, Any] | None:
|
|
82
|
+
"""The document of a TOML file, or ``None`` where there is no such file."""
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
text = path.read_bytes().decode("utf-8")
|
|
87
|
+
except UnicodeDecodeError:
|
|
88
|
+
raise OmertaError(f"{path.name} is not UTF-8") from None
|
|
89
|
+
except OSError as error:
|
|
90
|
+
raise OmertaError(f"{path.name} cannot be read ({error.strerror})") from None
|
|
91
|
+
try:
|
|
92
|
+
return tomllib.loads(text)
|
|
93
|
+
except tomllib.TOMLDecodeError as error:
|
|
94
|
+
raise OmertaError(f"{path.name}: {error}") from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate(table: dict[str, Any], source: str) -> dict[str, Any]:
|
|
98
|
+
"""The settings of a configuration table, checked key by key."""
|
|
99
|
+
for key, value in table.items():
|
|
100
|
+
if key not in _KEYS:
|
|
101
|
+
raise OmertaError(f"{source}: unknown setting {key!r}")
|
|
102
|
+
if key == "env-files" and not (_strings(value) and value):
|
|
103
|
+
raise OmertaError(f"{source}: env-files must be a non-empty list of patterns")
|
|
104
|
+
if key == "min-chars" and (type(value) is not int or value < 1):
|
|
105
|
+
raise OmertaError(f"{source}: min-chars must be an integer of at least 1")
|
|
106
|
+
if key == "ignore" and not _strings(value):
|
|
107
|
+
raise OmertaError(f"{source}: ignore must be a list of variable names")
|
|
108
|
+
if key == "encoded" and type(value) is not bool:
|
|
109
|
+
raise OmertaError(f"{source}: encoded must be true or false")
|
|
110
|
+
for pattern in table.get("env-files", []):
|
|
111
|
+
try:
|
|
112
|
+
Pattern(pattern)
|
|
113
|
+
except OmertaError as error:
|
|
114
|
+
raise OmertaError(f"{source}: {error}") from None
|
|
115
|
+
return table
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _strings(value: object) -> bool:
|
|
119
|
+
"""Whether a value is a list of strings."""
|
|
120
|
+
return isinstance(value, list) and all(isinstance(item, str) for item in value)
|
omerta/envfiles.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The env files of the working tree, and the variables they set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import fnmatch
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from dotenv.parser import parse_stream
|
|
14
|
+
|
|
15
|
+
from omerta.errors import OmertaError
|
|
16
|
+
from omerta.output import quote
|
|
17
|
+
from omerta.patterns import Pattern, is_template
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class EnvFile:
|
|
22
|
+
"""An env file that git does not track, a source of values."""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
"""Its path relative to the root of the repository, with ``/``."""
|
|
26
|
+
|
|
27
|
+
absolute: Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find(top: Path, patterns: tuple[Pattern, ...], tracked: set[bytes]) -> list[EnvFile]:
|
|
31
|
+
"""The env files of the working tree that git does not track.
|
|
32
|
+
|
|
33
|
+
Each pattern's folders are walked with :func:`os.scandir`: ``.git`` is never
|
|
34
|
+
entered, nor a folder that holds a ``.git`` of its own (a submodule, a
|
|
35
|
+
nested repository), nor a symbolic link to a folder; a symbolic link to a
|
|
36
|
+
file is read through. A folder that matches a pattern is no env file, and
|
|
37
|
+
neither is a template.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
top: The root of the repository.
|
|
41
|
+
patterns: The env file patterns, in order.
|
|
42
|
+
tracked: The paths of the index entries, as git gives them.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The env files in the order of the patterns, then of their paths.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
OmertaError: A folder on the way cannot be listed.
|
|
49
|
+
"""
|
|
50
|
+
found: dict[str, EnvFile] = {}
|
|
51
|
+
for pattern in patterns:
|
|
52
|
+
for parts in sorted(set(_walk(top, (), pattern.parts))):
|
|
53
|
+
path = "/".join(parts)
|
|
54
|
+
if path in found or is_template(parts[-1]) or os.fsencode(path) in tracked:
|
|
55
|
+
continue
|
|
56
|
+
found[path] = EnvFile(path, top.joinpath(*parts))
|
|
57
|
+
return list(found.values())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read(env_file: EnvFile) -> list[tuple[str, str | None]]:
|
|
61
|
+
"""The variables an env file sets, with their values, in the order of the file.
|
|
62
|
+
|
|
63
|
+
The file's bytes are decoded as UTF-16 after a UTF-16 byte order mark, and
|
|
64
|
+
as UTF-8 otherwise, with or without one; line ends are then made ``\\n``, as
|
|
65
|
+
Python's text mode reads a file. python-dotenv parses the text: quotes,
|
|
66
|
+
``export`` and comments, with no ``${VAR}`` expanded. A variable given twice
|
|
67
|
+
is listed twice. A key without ``=`` has the value ``None``.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
OmertaError: The file cannot be read, is not UTF-8 or UTF-16, or has a
|
|
71
|
+
statement that python-dotenv cannot parse. The error names the file
|
|
72
|
+
and the line, never its text.
|
|
73
|
+
"""
|
|
74
|
+
name = quote(os.fsencode(env_file.path))
|
|
75
|
+
try:
|
|
76
|
+
data = env_file.absolute.read_bytes()
|
|
77
|
+
except OSError as error:
|
|
78
|
+
raise OmertaError(f"{name} cannot be read ({error.strerror})") from None
|
|
79
|
+
text = _decode(data)
|
|
80
|
+
if text is None:
|
|
81
|
+
raise OmertaError(f"{name} is neither UTF-8 nor UTF-16 text")
|
|
82
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
83
|
+
bindings = []
|
|
84
|
+
for binding in parse_stream(io.StringIO(text)):
|
|
85
|
+
if binding.error:
|
|
86
|
+
# python-dotenv counts the line from before the blank lines that
|
|
87
|
+
# come ahead of the statement.
|
|
88
|
+
original = binding.original.string
|
|
89
|
+
blank = original[: len(original) - len(original.lstrip())]
|
|
90
|
+
line = binding.original.line + blank.count("\n")
|
|
91
|
+
raise OmertaError(f"{name}, line {line}: python-dotenv cannot parse this statement")
|
|
92
|
+
if binding.key is not None:
|
|
93
|
+
bindings.append((binding.key, binding.value))
|
|
94
|
+
return bindings
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _decode(data: bytes) -> str | None:
|
|
98
|
+
"""The text of an env file's bytes, or ``None`` where they do not decode."""
|
|
99
|
+
# The caller raises its own error after this returns, outside of the
|
|
100
|
+
# handler: the decoding error, which holds the bytes in question, is then
|
|
101
|
+
# not its context.
|
|
102
|
+
try:
|
|
103
|
+
if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
|
104
|
+
return data.decode("utf-16")
|
|
105
|
+
return data.removeprefix(codecs.BOM_UTF8).decode("utf-8")
|
|
106
|
+
except UnicodeDecodeError:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _walk(top: Path, folder: tuple[str, ...], rest: tuple[str, ...]) -> Iterator[tuple[str, ...]]:
|
|
111
|
+
"""The files under ``folder`` whose paths match the remaining pattern parts."""
|
|
112
|
+
head, tail = rest[0], rest[1:]
|
|
113
|
+
entries = _entries(top, folder)
|
|
114
|
+
if head == "**":
|
|
115
|
+
if tail:
|
|
116
|
+
yield from _walk(top, folder, tail)
|
|
117
|
+
else:
|
|
118
|
+
yield from ((*folder, name) for name, is_folder in entries if not is_folder)
|
|
119
|
+
for name, is_folder in entries:
|
|
120
|
+
if is_folder:
|
|
121
|
+
yield from _walk(top, (*folder, name), rest)
|
|
122
|
+
return
|
|
123
|
+
for name, is_folder in entries:
|
|
124
|
+
if not fnmatch.fnmatchcase(name, head):
|
|
125
|
+
continue
|
|
126
|
+
if tail and is_folder:
|
|
127
|
+
yield from _walk(top, (*folder, name), tail)
|
|
128
|
+
elif not tail and not is_folder:
|
|
129
|
+
yield (*folder, name)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _entries(top: Path, folder: tuple[str, ...]) -> list[tuple[str, bool]]:
|
|
133
|
+
"""The folders and files of a folder: each name, and whether it is a folder.
|
|
134
|
+
|
|
135
|
+
Left out are ``.git``, a folder holding a ``.git`` of its own, a symbolic
|
|
136
|
+
link to a folder, and what is neither a folder nor a file.
|
|
137
|
+
"""
|
|
138
|
+
path = top.joinpath(*folder)
|
|
139
|
+
entries = []
|
|
140
|
+
try:
|
|
141
|
+
with os.scandir(path) as listing:
|
|
142
|
+
for entry in listing:
|
|
143
|
+
if entry.name == ".git":
|
|
144
|
+
continue
|
|
145
|
+
if entry.is_dir(follow_symlinks=False):
|
|
146
|
+
if not os.path.lexists(os.path.join(entry.path, ".git")):
|
|
147
|
+
entries.append((entry.name, True))
|
|
148
|
+
elif entry.is_file():
|
|
149
|
+
entries.append((entry.name, False))
|
|
150
|
+
except OSError as error:
|
|
151
|
+
name = quote(os.fsencode("/".join(folder) or "."))
|
|
152
|
+
raise OmertaError(f"folder {name} cannot be listed ({error.strerror})") from None
|
|
153
|
+
return entries
|
omerta/errors.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The error that ends a run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OmertaError(Exception):
|
|
7
|
+
"""A failure that ends a run with exit code 2.
|
|
8
|
+
|
|
9
|
+
Its message says what failed and where (a file, a line, a git command),
|
|
10
|
+
never a value of an env file.
|
|
11
|
+
"""
|