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 ADDED
@@ -0,0 +1,5 @@
1
+ """Fail when a value of your env files is in git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
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
+ """