texmodel 0.2.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.
- texmodel/__init__.py +6 -0
- texmodel/_internal/__init__.py +1 -0
- texmodel/_internal/render.py +323 -0
- texmodel/_internal/scan.py +23 -0
- texmodel/body.py +364 -0
- texmodel/bundle.py +69 -0
- texmodel/cli.py +211 -0
- texmodel/components.py +757 -0
- texmodel/config.py +32 -0
- texmodel/drift.py +55 -0
- texmodel/errors.py +38 -0
- texmodel/package.py +482 -0
- texmodel/plan.py +92 -0
- texmodel/pointer.py +70 -0
- texmodel/preamble.py +110 -0
- texmodel/py.typed +0 -0
- texmodel/spec.py +235 -0
- texmodel/std/__init__.py +37 -0
- texmodel/std/adjustbox.py +19 -0
- texmodel/std/anyfontsize.py +7 -0
- texmodel/std/calc.py +7 -0
- texmodel/std/enumitem.py +18 -0
- texmodel/std/eso_pic.py +49 -0
- texmodel/std/etoolbox.py +127 -0
- texmodel/std/fontspec.py +36 -0
- texmodel/std/geometry.py +17 -0
- texmodel/std/graphicx.py +26 -0
- texmodel/std/hyphenat.py +7 -0
- texmodel/std/latex.py +516 -0
- texmodel/std/microtype.py +27 -0
- texmodel/std/pdfrender.py +17 -0
- texmodel/std/tcolorbox.py +43 -0
- texmodel/std/tikz.py +36 -0
- texmodel/std/xcolor.py +47 -0
- texmodel/testing.py +297 -0
- texmodel/types.py +264 -0
- texmodel-0.2.0.dist-info/METADATA +525 -0
- texmodel-0.2.0.dist-info/RECORD +61 -0
- texmodel-0.2.0.dist-info/WHEEL +4 -0
- texmodel-0.2.0.dist-info/entry_points.txt +9 -0
- texmodel-0.2.0.dist-info/licenses/LICENSE +21 -0
- texmodel_build/__init__.py +10 -0
- texmodel_build/_internal/__init__.py +1 -0
- texmodel_build/_internal/converge.py +19 -0
- texmodel_build/_internal/env.py +42 -0
- texmodel_build/_internal/logparse.py +170 -0
- texmodel_build/_internal/proofpdf.py +52 -0
- texmodel_build/_internal/spawn.py +36 -0
- texmodel_build/_internal/styscan.py +37 -0
- texmodel_build/cli.py +349 -0
- texmodel_build/compiler.py +341 -0
- texmodel_build/config.py +58 -0
- texmodel_build/diagnostics.py +225 -0
- texmodel_build/engines.py +35 -0
- texmodel_build/errors.py +17 -0
- texmodel_build/py.typed +0 -0
- texmodel_build/result.py +65 -0
- texmodel_build/roots.py +41 -0
- texmodel_build/targets.py +43 -0
- texmodel_build/testkit.py +59 -0
- texmodel_build/verify.py +158 -0
texmodel_build/result.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`CompileResult` (C1): a failed compile is a result, not an exception (E3)."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import pathlib
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
from .diagnostics import Diagnostic, Measurement, Severity, diagnostic_from_dict
|
|
8
|
+
from .errors import CompileError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
12
|
+
class CompileResult:
|
|
13
|
+
pdf: pathlib.Path | None
|
|
14
|
+
log: pathlib.Path | None
|
|
15
|
+
passes: int
|
|
16
|
+
pages: int | None
|
|
17
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
18
|
+
seconds: float
|
|
19
|
+
engine: str
|
|
20
|
+
engine_version: str
|
|
21
|
+
latexmk_version: str
|
|
22
|
+
image: str | None = None # None: ran on the host, not in the reference image
|
|
23
|
+
image_digest: str | None = None
|
|
24
|
+
image_revision: str | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ok(self) -> bool:
|
|
28
|
+
return self.pdf is not None and not self.errors
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def errors(self) -> tuple[Diagnostic, ...]:
|
|
32
|
+
return tuple(d for d in self.diagnostics if d.severity is Severity.ERROR)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def probes(self) -> dict[str, str]:
|
|
36
|
+
return {d.name: d.value for d in self.diagnostics if isinstance(d, Measurement)}
|
|
37
|
+
|
|
38
|
+
def probe(self, name: str) -> str:
|
|
39
|
+
return self.probes[name]
|
|
40
|
+
|
|
41
|
+
def raise_for_status(self) -> None:
|
|
42
|
+
if self.ok:
|
|
43
|
+
return
|
|
44
|
+
first = self.errors[0] if self.errors else None
|
|
45
|
+
raise CompileError(
|
|
46
|
+
f"compile failed: {len(self.errors)} error(s)"
|
|
47
|
+
+ (f", first: {first.code} {first.message}" if first else ""),
|
|
48
|
+
code=first.code if first else "TM-X099",
|
|
49
|
+
diagnostics=self.diagnostics,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
data = dataclasses.asdict(self)
|
|
54
|
+
data["pdf"] = str(self.pdf) if self.pdf else None
|
|
55
|
+
data["log"] = str(self.log) if self.log else None
|
|
56
|
+
data["diagnostics"] = [d.to_dict() for d in self.diagnostics]
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict[str, Any], **override: Any) -> Self:
|
|
61
|
+
data = {**data, **override}
|
|
62
|
+
data["pdf"] = pathlib.Path(data["pdf"]) if data["pdf"] else None
|
|
63
|
+
data["log"] = pathlib.Path(data["log"]) if data["log"] else None
|
|
64
|
+
data["diagnostics"] = tuple(diagnostic_from_dict(d) for d in data["diagnostics"])
|
|
65
|
+
return cls(**data)
|
texmodel_build/roots.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Ordered asset roots (C4): the first root holding a name wins, and the rest are reported as shadowed."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import logging
|
|
5
|
+
import pathlib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from texmodel.errors import ValidationError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass(frozen=True)
|
|
14
|
+
class Resolution:
|
|
15
|
+
name: str
|
|
16
|
+
path: pathlib.Path
|
|
17
|
+
shadowed: tuple[pathlib.Path, ...] = ()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve(name: str, roots: Sequence[pathlib.Path]) -> Resolution | None:
|
|
21
|
+
"""Find `name` under `roots`; the winner is the earliest root. Absolute names and `..` are refused (Z1)."""
|
|
22
|
+
rel = pathlib.PurePosixPath(name)
|
|
23
|
+
if rel.is_absolute() or ".." in rel.parts:
|
|
24
|
+
raise ValidationError(
|
|
25
|
+
f"asset name {name!r} escapes the asset roots", code="TM-C100", hint="use a relative name"
|
|
26
|
+
)
|
|
27
|
+
hits = [root / name for root in roots if (root / name).is_file()]
|
|
28
|
+
if not hits:
|
|
29
|
+
return None
|
|
30
|
+
if len(hits) > 1:
|
|
31
|
+
logger.warning("asset %s exists in %d roots; %s wins over %s", name, len(hits), hits[0], hits[1:])
|
|
32
|
+
return Resolution(name, hits[0], tuple(hits[1:]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def shadowing(roots: Sequence[pathlib.Path]) -> list[Resolution]:
|
|
36
|
+
"""Every name present in more than one root, with the winner and what it hides."""
|
|
37
|
+
seen: dict[str, list[pathlib.Path]] = {}
|
|
38
|
+
for root in roots:
|
|
39
|
+
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
|
40
|
+
seen.setdefault(path.relative_to(root).as_posix(), []).append(path)
|
|
41
|
+
return [Resolution(name, paths[0], tuple(paths[1:])) for name, paths in sorted(seen.items()) if len(paths) > 1]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Build every declared target (C10): reruns until stable are the compiler's, this adds parallelism and one summary."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import pathlib
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
|
|
8
|
+
from texmodel.pointer import load_plan
|
|
9
|
+
|
|
10
|
+
from .compiler import Compiler
|
|
11
|
+
from .config import Target
|
|
12
|
+
from .engines import get_engine
|
|
13
|
+
from .result import CompileResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass(frozen=True)
|
|
17
|
+
class BuildSummary:
|
|
18
|
+
results: dict[str, CompileResult]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def ok(self) -> bool:
|
|
22
|
+
return all(r.ok for r in self.results.values())
|
|
23
|
+
|
|
24
|
+
def lines(self) -> list[str]:
|
|
25
|
+
return [
|
|
26
|
+
f"{'ok ' if r.ok else 'FAILED'} {name}: {len(r.errors)} error(s), {r.pages} page(s), {r.seconds}s"
|
|
27
|
+
for name, r in self.results.items()
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_targets(
|
|
32
|
+
targets: Iterable[Target], compiler: Compiler, *, root: pathlib.Path, outdir: pathlib.Path, jobs: int = 1
|
|
33
|
+
) -> BuildSummary:
|
|
34
|
+
"""Compile each target into `outdir/<name>`; the engine comes from the target. Results keep declared order."""
|
|
35
|
+
chosen = list(targets)
|
|
36
|
+
|
|
37
|
+
def one(target: Target) -> CompileResult:
|
|
38
|
+
mine = dataclasses.replace(compiler, engine=get_engine(target.engine))
|
|
39
|
+
plan = load_plan(target.plan, root) if target.plan else None
|
|
40
|
+
return mine.compile(root / target.source, outdir / target.name, plan=plan)
|
|
41
|
+
|
|
42
|
+
with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
|
|
43
|
+
return BuildSummary({t.name: r for t, r in zip(chosen, pool.map(one, chosen), strict=True)})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Contract checks a verifier plugin must pass (L1). Plain asserts, so any test runner can call them."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from texmodel.errors import Violation
|
|
8
|
+
from texmodel.package import Package, PreambleSlots
|
|
9
|
+
from texmodel.plan import Plan
|
|
10
|
+
from texmodel.testing import check_exports, export_probes
|
|
11
|
+
from texmodel.types import Raw
|
|
12
|
+
|
|
13
|
+
from .compiler import Compiler
|
|
14
|
+
from .diagnostics import Measurement
|
|
15
|
+
from .verify import Profile, Verifier, verify_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_probes(
|
|
19
|
+
compiler: Compiler, tex: str, files: Mapping[str, str] | None = None, *, plans: Mapping[str, Plan] | None = None
|
|
20
|
+
) -> list[Measurement]:
|
|
21
|
+
"""Compile `tex` and return its `texmodel:probe:` lines in log order (§21a 20, C5a). `files` are written beside it
|
|
22
|
+
first (path -> text: the `.sty` the document loads); the build directory is removed afterwards. A document that
|
|
23
|
+
does not compile raises `CompileError`, so a test never reads probes from a broken run; its diagnostics carry the
|
|
24
|
+
`path` from `plans` (file name -> the plan that wrote it)."""
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
26
|
+
work = pathlib.Path(tmp).resolve() / "src"
|
|
27
|
+
for rel, text in {**(files or {}), "probe.tex": tex}.items():
|
|
28
|
+
path = work / rel
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
path.write_text(text, encoding="utf-8")
|
|
31
|
+
result = compiler.compile(work / "probe.tex", pathlib.Path(tmp).resolve() / "out", plans=plans)
|
|
32
|
+
result.raise_for_status()
|
|
33
|
+
return [d for d in result.diagnostics if isinstance(d, Measurement)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_library(compiler: Compiler, package: Package, files: Mapping[str, str] | None = None) -> list[Violation]:
|
|
37
|
+
"""Compile `package` and check that every component a library block defines exists with the declared arguments
|
|
38
|
+
(§21a 12): `Package.test_document` with `export_probes`, run with `run_probes`, read by `check_exports`. A block
|
|
39
|
+
TeX refuses raises `CompileError` whose diagnostics name the block as their `path`. `files` are other files the
|
|
40
|
+
package loads."""
|
|
41
|
+
tex = package.test_document(preamble=PreambleSlots(after_package=(Raw(export_probes(package)),)))
|
|
42
|
+
sty = f"{package.name}.sty"
|
|
43
|
+
found = run_probes(compiler, tex, {sty: package.render(), **(files or {})}, plans={sty: package.render_plan()})
|
|
44
|
+
return check_exports(package, {m.name: m.value for m in found})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_verifier_contract(verifier: Verifier, tmp_path: pathlib.Path) -> None:
|
|
48
|
+
"""A verifier names itself, and a missing file or an unavailable tool fails; neither passes nor raises."""
|
|
49
|
+
assert isinstance(verifier.name, str) and verifier.name, "a verifier needs a non-empty name"
|
|
50
|
+
available = {verifier.name: verifier}
|
|
51
|
+
profile = Profile(checks=(verifier.name,))
|
|
52
|
+
report = verify_file(tmp_path / "does-not-exist", profile, available)
|
|
53
|
+
assert not report.ok, "a missing file must fail, not pass"
|
|
54
|
+
why = verifier.unavailable({})
|
|
55
|
+
assert why is None or isinstance(why, str), "unavailable() returns a reason or None"
|
|
56
|
+
if why is not None:
|
|
57
|
+
present = tmp_path / "present"
|
|
58
|
+
present.write_bytes(b"x")
|
|
59
|
+
assert not verify_file(present, profile, available).ok, "an unavailable verifier must fail the file"
|
texmodel_build/verify.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Verifier plugins (V4, V5, L1): checks over a built file, discovered through entry points.
|
|
2
|
+
|
|
3
|
+
A verifier is any object with a `name`, an `unavailable(options)` that says why it cannot run here (or `None`), and a
|
|
4
|
+
`verify(path, options)` returning findings. Register it in the `texmodel_build.verifiers` entry-point group, as a
|
|
5
|
+
callable that returns the verifier.
|
|
6
|
+
|
|
7
|
+
The rule that matters is V5, **unconfirmable means failure**: a verifier that cannot run, a file that cannot be opened,
|
|
8
|
+
a plugin that crashes, and a profile that selects nothing are all errors, never skips. The only way to excuse a file is
|
|
9
|
+
the exemption registry, which keeps it listed with its reason.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
import fnmatch
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
import pathlib
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from typing import Any, Protocol
|
|
19
|
+
|
|
20
|
+
from .diagnostics import Severity
|
|
21
|
+
from .errors import ToolchainError
|
|
22
|
+
|
|
23
|
+
GROUP = "texmodel_build.verifiers"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(frozen=True)
|
|
27
|
+
class Finding:
|
|
28
|
+
"""One issue a check raised, optionally tied to a 1-based page."""
|
|
29
|
+
|
|
30
|
+
check: str
|
|
31
|
+
severity: Severity
|
|
32
|
+
message: str
|
|
33
|
+
page: int | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
37
|
+
class Profile:
|
|
38
|
+
"""Which verifiers to run (empty: every registered one) and their options, keyed by verifier name."""
|
|
39
|
+
|
|
40
|
+
name: str = "default"
|
|
41
|
+
checks: tuple[str, ...] = ()
|
|
42
|
+
options: Mapping[str, Mapping[str, Any]] = dataclasses.field(default_factory=dict[str, Mapping[str, Any]])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass(frozen=True)
|
|
46
|
+
class FileReport:
|
|
47
|
+
path: pathlib.Path
|
|
48
|
+
findings: tuple[Finding, ...] = ()
|
|
49
|
+
exempt: str | None = None # why the file was declared out of scope; listed, never a failure
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def errors(self) -> tuple[Finding, ...]:
|
|
53
|
+
return tuple(f for f in self.findings if f.severity is Severity.ERROR)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return not self.errors
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Verifier(Protocol):
|
|
61
|
+
name: str
|
|
62
|
+
|
|
63
|
+
def unavailable(self, options: Mapping[str, Any]) -> str | None:
|
|
64
|
+
"""Why this verifier cannot run here with these options (a missing tool, say), or `None`."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def verify(self, path: pathlib.Path, options: Mapping[str, Any]) -> Sequence[Finding]: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def registered() -> dict[str, Verifier]:
|
|
71
|
+
"""Every verifier in the entry-point group. A plugin that fails to load is an error, not a silent omission."""
|
|
72
|
+
found: dict[str, Verifier] = {}
|
|
73
|
+
for entry in importlib.metadata.entry_points(group=GROUP):
|
|
74
|
+
try:
|
|
75
|
+
verifier: Verifier = entry.load()()
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise ToolchainError(
|
|
78
|
+
f"verifier plugin {entry.name!r} failed to load: {exc!r}", code="TM-V001", hint="reinstall the plugin"
|
|
79
|
+
) from exc
|
|
80
|
+
found[verifier.name] = verifier
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def exemption(path: pathlib.PurePath, exempt: Mapping[str, str]) -> str | None:
|
|
85
|
+
"""The reason `path` is exempt: a glob matching the path or any trailing part of it (`res/**`, `*_preview.pdf`)."""
|
|
86
|
+
parts = path.as_posix().split("/")
|
|
87
|
+
tails = ["/".join(parts[i:]) for i in range(len(parts))]
|
|
88
|
+
for glob, reason in exempt.items():
|
|
89
|
+
if any(fnmatch.fnmatch(tail, glob) for tail in tails):
|
|
90
|
+
return reason
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def verify_file(
|
|
95
|
+
path: pathlib.Path,
|
|
96
|
+
profile: Profile,
|
|
97
|
+
available: Mapping[str, Verifier],
|
|
98
|
+
exempt: Mapping[str, str] | None = None,
|
|
99
|
+
) -> FileReport:
|
|
100
|
+
"""Run the profile's verifiers over one file. Reports; never raises for a bad file or a bad plugin."""
|
|
101
|
+
if reason := exemption(path, exempt or {}):
|
|
102
|
+
return FileReport(path, exempt=reason)
|
|
103
|
+
names = profile.checks or tuple(available)
|
|
104
|
+
if not names:
|
|
105
|
+
return FileReport(
|
|
106
|
+
path, (Finding("verify", Severity.ERROR, "no verifier selected or installed: nothing was checked"),)
|
|
107
|
+
)
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
return FileReport(path, (Finding("open", Severity.ERROR, "no such file"),))
|
|
110
|
+
findings: list[Finding] = []
|
|
111
|
+
for name in names:
|
|
112
|
+
verifier = available.get(name)
|
|
113
|
+
if verifier is None:
|
|
114
|
+
findings.append(Finding(name, Severity.ERROR, f"unknown verifier; installed: {sorted(available)}"))
|
|
115
|
+
continue
|
|
116
|
+
options = profile.options.get(name, {})
|
|
117
|
+
if why := verifier.unavailable(options):
|
|
118
|
+
findings.append(Finding(name, Severity.ERROR, f"cannot confirm: {why}"))
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
findings.extend(verifier.verify(path, options))
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
findings.append(Finding(name, Severity.ERROR, f"verifier crashed: {exc!r}"))
|
|
124
|
+
return FileReport(path, tuple(findings))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_files(
|
|
128
|
+
paths: Sequence[pathlib.Path],
|
|
129
|
+
profile: Profile,
|
|
130
|
+
available: Mapping[str, Verifier],
|
|
131
|
+
exempt: Mapping[str, str] | None = None,
|
|
132
|
+
*,
|
|
133
|
+
jobs: int = 1,
|
|
134
|
+
) -> list[FileReport]:
|
|
135
|
+
"""One report per path, in order."""
|
|
136
|
+
|
|
137
|
+
def one(path: pathlib.Path) -> FileReport:
|
|
138
|
+
return verify_file(path, profile, available, exempt)
|
|
139
|
+
|
|
140
|
+
with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
|
|
141
|
+
return list(pool.map(one, paths))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def expand_targets(
|
|
145
|
+
paths: Sequence[pathlib.Path], exclude: Sequence[str] = (), suffix: str = ".pdf"
|
|
146
|
+
) -> list[pathlib.Path]:
|
|
147
|
+
"""Files as given; a directory expands to every `suffix` file beneath it minus `exclude` globs (relative to it)."""
|
|
148
|
+
out: list[pathlib.Path] = []
|
|
149
|
+
for path in paths:
|
|
150
|
+
if path.is_dir():
|
|
151
|
+
out += [
|
|
152
|
+
p
|
|
153
|
+
for p in sorted(path.rglob(f"*{suffix}"))
|
|
154
|
+
if exemption(p.relative_to(path), dict.fromkeys(exclude, "")) is None
|
|
155
|
+
]
|
|
156
|
+
else:
|
|
157
|
+
out.append(path)
|
|
158
|
+
return out
|