conflint 0.1.1__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.
- conflint/__about__.py +7 -0
- conflint/__init__.py +32 -0
- conflint/__main__.py +8 -0
- conflint/_version.py +24 -0
- conflint/cli.py +374 -0
- conflint/config.py +268 -0
- conflint/discovery.py +167 -0
- conflint/engine.py +182 -0
- conflint/exceptions.py +60 -0
- conflint/models.py +302 -0
- conflint/registry.py +163 -0
- conflint/reporters/__init__.py +50 -0
- conflint/reporters/base.py +31 -0
- conflint/reporters/github.py +45 -0
- conflint/reporters/json.py +37 -0
- conflint/reporters/junit.py +70 -0
- conflint/reporters/sarif.py +103 -0
- conflint/reporters/text.py +102 -0
- conflint/rules/__init__.py +44 -0
- conflint/rules/base.py +61 -0
- conflint/rules/drift.py +38 -0
- conflint/rules/duplicate.py +70 -0
- conflint/rules/empty.py +38 -0
- conflint/rules/missing.py +38 -0
- conflint/rules/secrets.py +39 -0
- conflint/rules/typo.py +39 -0
- conflint/rules/undocumented.py +48 -0
- conflint/rules/unused.py +40 -0
- conflint/rules/weak.py +75 -0
- conflint/scanners/__init__.py +21 -0
- conflint/scanners/base.py +49 -0
- conflint/scanners/go.py +53 -0
- conflint/scanners/javascript.py +115 -0
- conflint/scanners/python.py +196 -0
- conflint/scanners/ruby.py +80 -0
- conflint/sources/__init__.py +19 -0
- conflint/sources/_yaml.py +25 -0
- conflint/sources/base.py +66 -0
- conflint/sources/docker_compose.py +88 -0
- conflint/sources/dotenv.py +83 -0
- conflint/sources/github_actions.py +77 -0
- conflint/sources/kubernetes.py +146 -0
- conflint/sources/terraform.py +168 -0
- conflint/sync.py +123 -0
- conflint/utils.py +263 -0
- conflint/version.py +30 -0
- conflint-0.1.1.dist-info/METADATA +255 -0
- conflint-0.1.1.dist-info/RECORD +51 -0
- conflint-0.1.1.dist-info/WHEEL +4 -0
- conflint-0.1.1.dist-info/entry_points.txt +26 -0
- conflint-0.1.1.dist-info/licenses/LICENSE +21 -0
conflint/__about__.py
ADDED
conflint/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""conflint - code-aware config linter.
|
|
2
|
+
|
|
3
|
+
Find every environment variable, config key and secret your application reads,
|
|
4
|
+
reconcile it against every place it is defined (.env, Docker, CI, Kubernetes,
|
|
5
|
+
Terraform) and fail fast on the drift that silently breaks production.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from conflint.models import (
|
|
11
|
+
Config,
|
|
12
|
+
Entry,
|
|
13
|
+
Finding,
|
|
14
|
+
Language,
|
|
15
|
+
Location,
|
|
16
|
+
Report,
|
|
17
|
+
Severity,
|
|
18
|
+
Usage,
|
|
19
|
+
)
|
|
20
|
+
from conflint.version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Config",
|
|
24
|
+
"Entry",
|
|
25
|
+
"Finding",
|
|
26
|
+
"Language",
|
|
27
|
+
"Location",
|
|
28
|
+
"Report",
|
|
29
|
+
"Severity",
|
|
30
|
+
"Usage",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
conflint/__main__.py
ADDED
conflint/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
conflint/cli.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""conflint command-line interface.
|
|
2
|
+
|
|
3
|
+
The command name is :program:`confl` (the PyPI package is ``conflint``)::
|
|
4
|
+
|
|
5
|
+
pip install conflint
|
|
6
|
+
confl check
|
|
7
|
+
confl sync
|
|
8
|
+
confl explain DB_HOST
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from conflint.__about__ import __version__
|
|
20
|
+
from conflint.config import Settings, load_settings
|
|
21
|
+
from conflint.engine import Analyzer
|
|
22
|
+
from conflint.exceptions import ConflintError, ExitCode
|
|
23
|
+
from conflint.models import Report
|
|
24
|
+
from conflint.registry import get_rules
|
|
25
|
+
from conflint.reporters import (
|
|
26
|
+
GithubReporter,
|
|
27
|
+
JsonReporter,
|
|
28
|
+
JunitReporter,
|
|
29
|
+
Reporter,
|
|
30
|
+
SarifReporter,
|
|
31
|
+
TextReporter,
|
|
32
|
+
)
|
|
33
|
+
from conflint.sync import build_example, default_example_file
|
|
34
|
+
from conflint.utils import read_text
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("conflint.cli")
|
|
37
|
+
|
|
38
|
+
__all__ = ["main"]
|
|
39
|
+
|
|
40
|
+
_REPORTERS: dict[str, type[Reporter]] = {
|
|
41
|
+
"text": TextReporter,
|
|
42
|
+
"json": JsonReporter,
|
|
43
|
+
"sarif": SarifReporter,
|
|
44
|
+
"github": GithubReporter,
|
|
45
|
+
"junit": JunitReporter,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
50
|
+
@click.version_option(__version__, prog_name="confl", message="%(prog)s %(version)s")
|
|
51
|
+
def main() -> None:
|
|
52
|
+
"""conflint - code-aware config linter.
|
|
53
|
+
|
|
54
|
+
Find the env vars, config keys and secrets your app *actually* reads, then
|
|
55
|
+
reconcile them against .env, CI, Docker, Kubernetes and Terraform.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------
|
|
60
|
+
# check
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
@main.command()
|
|
63
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
64
|
+
@click.option("--config", "config_path", type=click.Path(dir_okay=False))
|
|
65
|
+
@click.option("--report", "report_flags", multiple=True, type=click.Choice(sorted(_REPORTERS)))
|
|
66
|
+
@click.option("--fail-level", type=click.Choice(["error", "warning", "info"]))
|
|
67
|
+
@click.option("--fix", is_flag=True, help="Auto-remediate fixable findings.")
|
|
68
|
+
@click.option("--no-color", "no_color", is_flag=True)
|
|
69
|
+
@click.option("--json", "as_json", is_flag=True, hidden=False)
|
|
70
|
+
@click.option("--sarif", "as_sarif", is_flag=True)
|
|
71
|
+
@click.option("--github", "as_github", is_flag=True)
|
|
72
|
+
@click.option("--output", type=click.Path(dir_okay=False), help="Write the report to a file.")
|
|
73
|
+
def check(
|
|
74
|
+
paths: tuple[str, ...],
|
|
75
|
+
config_path: str | None,
|
|
76
|
+
report_flags: tuple[str, ...],
|
|
77
|
+
fail_level: str | None,
|
|
78
|
+
fix: bool,
|
|
79
|
+
no_color: bool,
|
|
80
|
+
as_json: bool,
|
|
81
|
+
as_sarif: bool,
|
|
82
|
+
as_github: bool,
|
|
83
|
+
output: str | None,
|
|
84
|
+
) -> int:
|
|
85
|
+
"""Lint the project and report configuration drift."""
|
|
86
|
+
|
|
87
|
+
root = _resolve_root(paths)
|
|
88
|
+
try:
|
|
89
|
+
settings = load_settings(
|
|
90
|
+
root,
|
|
91
|
+
config_path=Path(config_path) if config_path else None,
|
|
92
|
+
overrides=_build_overrides(paths, fail_level, no_color),
|
|
93
|
+
)
|
|
94
|
+
except ConflintError as exc:
|
|
95
|
+
click.echo(f"confl: {exc}", err=True)
|
|
96
|
+
raise SystemExit(ExitCode.USAGE) from None
|
|
97
|
+
reporter_classes = _select_reporters(settings, report_flags, as_json, as_sarif, as_github)
|
|
98
|
+
if not reporter_classes:
|
|
99
|
+
click.echo("confl: no reporters selected", err=True)
|
|
100
|
+
raise SystemExit(ExitCode.USAGE)
|
|
101
|
+
try:
|
|
102
|
+
analyzer = Analyzer(settings)
|
|
103
|
+
if fix:
|
|
104
|
+
_autofix(analyzer, settings)
|
|
105
|
+
report = analyzer.run()
|
|
106
|
+
except ConflintError as exc:
|
|
107
|
+
click.echo(f"confl: {exc}", err=True)
|
|
108
|
+
raise SystemExit(ExitCode.INTERNAL) from None
|
|
109
|
+
_emit(reporter_classes, report, settings, output)
|
|
110
|
+
|
|
111
|
+
if report.has_findings_at_or_above(settings.fail_level):
|
|
112
|
+
raise SystemExit(ExitCode.FINDINGS)
|
|
113
|
+
raise SystemExit(ExitCode.OK)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------------------
|
|
117
|
+
# sync
|
|
118
|
+
# --------------------------------------------------------------------------
|
|
119
|
+
@main.command()
|
|
120
|
+
@click.option("--config", "config_path", type=click.Path(dir_okay=False))
|
|
121
|
+
@click.option("--dry-run", is_flag=True, help="Print the generated file without writing.")
|
|
122
|
+
@click.option("--output", type=click.Path(dir_okay=False), help="Example file to write/update.")
|
|
123
|
+
def sync(config_path: str | None, dry_run: bool, output: str | None) -> int:
|
|
124
|
+
"""Regenerate the .env.example from code + config sources."""
|
|
125
|
+
|
|
126
|
+
root = Path.cwd()
|
|
127
|
+
try:
|
|
128
|
+
settings = load_settings(root, config_path=Path(config_path) if config_path else None)
|
|
129
|
+
model = Analyzer(settings).collect()
|
|
130
|
+
except ConflintError as exc:
|
|
131
|
+
click.echo(f"confl: {exc}", err=True)
|
|
132
|
+
raise SystemExit(ExitCode.USAGE) from None
|
|
133
|
+
content = build_example(model, settings)
|
|
134
|
+
if dry_run:
|
|
135
|
+
click.echo(content, nl=False)
|
|
136
|
+
raise SystemExit(ExitCode.OK)
|
|
137
|
+
target = Path(output) if output else default_example_file(settings.root, settings)
|
|
138
|
+
changed = target.is_file() and read_text(target) == content
|
|
139
|
+
try:
|
|
140
|
+
target.write_text(content, encoding="utf-8")
|
|
141
|
+
except OSError as exc: # pragma: no cover - filesystem dependent
|
|
142
|
+
click.echo(f"confl: cannot write {target}: {exc}", err=True)
|
|
143
|
+
raise SystemExit(ExitCode.INTERNAL) from None
|
|
144
|
+
click.echo(("unchanged: " if changed else "updated: ") + str(target))
|
|
145
|
+
raise SystemExit(ExitCode.OK)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------
|
|
149
|
+
# explain
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
@main.command("explain")
|
|
152
|
+
@click.argument("name")
|
|
153
|
+
@click.option("--config", "config_path", type=click.Path(dir_okay=False))
|
|
154
|
+
def explain(name: str, config_path: str | None) -> int:
|
|
155
|
+
"""Show where a configuration value is used and defined."""
|
|
156
|
+
|
|
157
|
+
root = Path.cwd()
|
|
158
|
+
try:
|
|
159
|
+
settings = load_settings(root, config_path=Path(config_path) if config_path else None)
|
|
160
|
+
analyzer = Analyzer(settings)
|
|
161
|
+
model = analyzer.collect()
|
|
162
|
+
except ConflintError as exc:
|
|
163
|
+
click.echo(f"confl: {exc}", err=True)
|
|
164
|
+
raise SystemExit(ExitCode.USAGE) from None
|
|
165
|
+
usages = model.usages_by_name().get(name, [])
|
|
166
|
+
entries = model.entries_by_name().get(name, [])
|
|
167
|
+
from rich.console import Console
|
|
168
|
+
|
|
169
|
+
console = Console()
|
|
170
|
+
if not usages and not entries:
|
|
171
|
+
console.print(f"[red]'{name}' is not used or defined anywhere.[/red]")
|
|
172
|
+
raise SystemExit(ExitCode.FINDINGS)
|
|
173
|
+
|
|
174
|
+
console.print(f"[bold]{name}[/bold]")
|
|
175
|
+
if usages:
|
|
176
|
+
console.print(f" read at [bold]{len(usages)}[/bold] place(s):")
|
|
177
|
+
seen = set()
|
|
178
|
+
for usage in usages:
|
|
179
|
+
line = str(usage.location.relative_to(model.root))
|
|
180
|
+
if line in seen:
|
|
181
|
+
continue
|
|
182
|
+
seen.add(line)
|
|
183
|
+
detail = f" - {line} (via {usage.read_via or '?'}"
|
|
184
|
+
if usage.has_default:
|
|
185
|
+
detail += f", default={usage.default!r})"
|
|
186
|
+
else:
|
|
187
|
+
detail += ")"
|
|
188
|
+
console.print(detail)
|
|
189
|
+
if entries:
|
|
190
|
+
console.print(f" defined in [bold]{len(entries)}[/bold] place(s):")
|
|
191
|
+
for entry in entries:
|
|
192
|
+
console.print(
|
|
193
|
+
f" - {entry.location.relative_to(model.root)} "
|
|
194
|
+
f"({entry.source}, value={entry.value!r}, example={entry.is_example})"
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
console.print(" not defined in any configuration source")
|
|
198
|
+
raise SystemExit(ExitCode.OK)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------
|
|
202
|
+
# rules / init / version
|
|
203
|
+
# --------------------------------------------------------------------------
|
|
204
|
+
@main.command("rules")
|
|
205
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
206
|
+
def rules(as_json: bool) -> int:
|
|
207
|
+
"""List every rule with its default severity."""
|
|
208
|
+
|
|
209
|
+
items = []
|
|
210
|
+
for _rule_id, rule in get_rules().items():
|
|
211
|
+
items.append(
|
|
212
|
+
{
|
|
213
|
+
"id": rule.id,
|
|
214
|
+
"name": rule.name,
|
|
215
|
+
"severity": rule.severity.value,
|
|
216
|
+
"description": rule.description,
|
|
217
|
+
"fixable": rule.fixable,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
if as_json:
|
|
221
|
+
import json as _json
|
|
222
|
+
|
|
223
|
+
click.echo(_json.dumps(items, indent=2))
|
|
224
|
+
raise SystemExit(ExitCode.OK)
|
|
225
|
+
click.echo(f"{'Rule':<8} {'Severity':<9} {'Name':<16} Description")
|
|
226
|
+
for item in items:
|
|
227
|
+
click.echo(
|
|
228
|
+
f"{item['id']:<8} {item['severity']:<9} {item['name']:<16} {item['description']}"
|
|
229
|
+
)
|
|
230
|
+
raise SystemExit(ExitCode.OK)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@main.command("init")
|
|
234
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing conflint.toml.")
|
|
235
|
+
def init(force: bool) -> int:
|
|
236
|
+
"""Write a template conflint.toml in the current directory."""
|
|
237
|
+
|
|
238
|
+
target = Path.cwd() / "conflint.toml"
|
|
239
|
+
if target.exists() and not force:
|
|
240
|
+
click.echo(f"confl: {target} already exists (use --force to overwrite).", err=True)
|
|
241
|
+
raise SystemExit(ExitCode.USAGE)
|
|
242
|
+
template = """\
|
|
243
|
+
# conflint configuration - https://github.com/conflint/conflint
|
|
244
|
+
# Keys are top-level here (in pyproject.toml, nest them under [tool.conflint]).
|
|
245
|
+
|
|
246
|
+
# fail-level = "error" # error | warning | info
|
|
247
|
+
# require-example = true # enforce a populated .env.example
|
|
248
|
+
|
|
249
|
+
# enable = { CL003 = "error" } # raise a rule's severity
|
|
250
|
+
# disable = ["CL002", "CL009"] # turn rules off
|
|
251
|
+
|
|
252
|
+
# ignore-names = ["CI=true", "NODE_ENV=*"]
|
|
253
|
+
# ignore-paths = ["scripts/**"]
|
|
254
|
+
|
|
255
|
+
# sources = ["dotenv", "docker-compose", "github-actions", "kubernetes", "terraform"]
|
|
256
|
+
# scanners = ["python", "javascript"]
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
target.write_text(template, encoding="utf-8")
|
|
260
|
+
except OSError as exc: # pragma: no cover
|
|
261
|
+
click.echo(f"confl: cannot write {target}: {exc}", err=True)
|
|
262
|
+
raise SystemExit(ExitCode.INTERNAL) from None
|
|
263
|
+
click.echo(f"created {target}")
|
|
264
|
+
raise SystemExit(ExitCode.OK)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@main.command()
|
|
268
|
+
def version() -> int:
|
|
269
|
+
"""Print the installed version."""
|
|
270
|
+
|
|
271
|
+
click.echo(f"confl {__version__}")
|
|
272
|
+
raise SystemExit(ExitCode.OK)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
# helpers
|
|
277
|
+
# --------------------------------------------------------------------------
|
|
278
|
+
def _resolve_root(paths: Sequence[str]) -> Path:
|
|
279
|
+
if not paths:
|
|
280
|
+
return Path.cwd()
|
|
281
|
+
first = Path(paths[0])
|
|
282
|
+
if first.is_dir():
|
|
283
|
+
return first.resolve()
|
|
284
|
+
return first.parent.resolve()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _build_overrides(
|
|
288
|
+
paths: Sequence[str],
|
|
289
|
+
fail_level: str | None,
|
|
290
|
+
no_color: bool,
|
|
291
|
+
) -> dict[str, object]:
|
|
292
|
+
overrides: dict[str, object] = {}
|
|
293
|
+
if paths:
|
|
294
|
+
root = _resolve_root(paths)
|
|
295
|
+
includes: list[str] = []
|
|
296
|
+
for raw in paths:
|
|
297
|
+
path = Path(raw)
|
|
298
|
+
if path.is_dir():
|
|
299
|
+
# Discovery is already rooted at the path; keep the default
|
|
300
|
+
# include glob ("**/*") rather than emitting an absolute glob.
|
|
301
|
+
continue
|
|
302
|
+
try:
|
|
303
|
+
rel = path.resolve().relative_to(root.resolve())
|
|
304
|
+
except (ValueError, OSError):
|
|
305
|
+
rel = path
|
|
306
|
+
includes.append(str(rel).replace("\\", "/"))
|
|
307
|
+
if includes:
|
|
308
|
+
overrides["include"] = tuple(includes)
|
|
309
|
+
if fail_level:
|
|
310
|
+
overrides["fail-level"] = fail_level
|
|
311
|
+
if no_color:
|
|
312
|
+
overrides["color"] = False
|
|
313
|
+
return overrides
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _select_reporters(
|
|
317
|
+
settings: Settings,
|
|
318
|
+
flags: Sequence[str],
|
|
319
|
+
as_json: bool,
|
|
320
|
+
as_sarif: bool,
|
|
321
|
+
as_github: bool,
|
|
322
|
+
) -> list[type[Reporter]]:
|
|
323
|
+
chosen: list[str] = []
|
|
324
|
+
if as_json:
|
|
325
|
+
chosen.append("json")
|
|
326
|
+
if as_sarif:
|
|
327
|
+
chosen.append("sarif")
|
|
328
|
+
if as_github:
|
|
329
|
+
chosen.append("github")
|
|
330
|
+
if not chosen:
|
|
331
|
+
chosen.extend(flags)
|
|
332
|
+
if not chosen:
|
|
333
|
+
chosen.extend(settings.reporters)
|
|
334
|
+
unique = list(dict.fromkeys(chosen))
|
|
335
|
+
return [_REPORTERS[name] for name in unique if name in _REPORTERS]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _emit(
|
|
339
|
+
reporter_classes: Sequence[type[Reporter]],
|
|
340
|
+
report: Report,
|
|
341
|
+
settings: Settings,
|
|
342
|
+
output: str | None,
|
|
343
|
+
) -> None:
|
|
344
|
+
rendered = [(cls.name, cls().render(report, settings)) for cls in reporter_classes]
|
|
345
|
+
if output is not None and len(rendered) == 1:
|
|
346
|
+
path = Path(output)
|
|
347
|
+
try:
|
|
348
|
+
path.write_text(rendered[0][1], encoding="utf-8")
|
|
349
|
+
except OSError as exc: # pragma: no cover
|
|
350
|
+
click.echo(f"confl: cannot write {path}: {exc}", err=True)
|
|
351
|
+
return
|
|
352
|
+
for _, text in rendered:
|
|
353
|
+
click.echo(text, nl=text.endswith("\n"))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _autofix(analyzer: Analyzer, settings: Settings) -> None:
|
|
357
|
+
"""Apply safe, lossless auto-fixes.
|
|
358
|
+
|
|
359
|
+
Currently resolves CL009 (undocumented keys) by rewriting the example file.
|
|
360
|
+
All fixes are deterministic and never touch real configuration values.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
if not settings.require_example:
|
|
364
|
+
return
|
|
365
|
+
model = analyzer.collect()
|
|
366
|
+
content = build_example(model, settings)
|
|
367
|
+
target = default_example_file(settings.root, settings)
|
|
368
|
+
if target.is_file() and read_text(target) == content:
|
|
369
|
+
return
|
|
370
|
+
target.write_text(content, encoding="utf-8")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__": # pragma: no cover
|
|
374
|
+
main()
|