obelize 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.
- obelize/__init__.py +5 -0
- obelize/__main__.py +5 -0
- obelize/_yaml.py +53 -0
- obelize/cli.py +634 -0
- obelize/commands/__init__.py +29 -0
- obelize/commands/fix.py +458 -0
- obelize/commands/undo.py +141 -0
- obelize/commands/verify.py +168 -0
- obelize/config.py +335 -0
- obelize/evidence/__init__.py +0 -0
- obelize/evidence/folder.py +125 -0
- obelize/evidence/patch.py +161 -0
- obelize/evidence/report.py +723 -0
- obelize/evidence/run_dir.py +863 -0
- obelize/fsutil.py +333 -0
- obelize/gitutil.py +133 -0
- obelize/impact/__init__.py +0 -0
- obelize/impact/dataflow.py +106 -0
- obelize/impact/planner.py +203 -0
- obelize/models.py +2065 -0
- obelize/native/__init__.py +1 -0
- obelize/native/_win32.py +683 -0
- obelize/native/console.py +10 -0
- obelize/native/console_posix.py +14 -0
- obelize/native/console_windows.py +21 -0
- obelize/native/files.py +61 -0
- obelize/native/files_posix.py +94 -0
- obelize/native/files_windows.py +263 -0
- obelize/native/processes.py +49 -0
- obelize/native/processes_posix.py +101 -0
- obelize/native/processes_windows.py +161 -0
- obelize/native/shell.py +10 -0
- obelize/native/windows_names.py +38 -0
- obelize/native/windows_programs.py +125 -0
- obelize/packs/__init__.py +0 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/SOURCES.md +183 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/already_new.py +17 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/chat_history_new_sdk.py +17 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/configure_new_sdk_client.py +11 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/embed_content_new_sdk.py +15 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/files_surface_new_sdk.py +27 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/local_genai_package.py +9 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/models_surface_new_sdk.py +13 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/no_out_of_scope_surface.py +16 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/no_tools.py +21 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/patch_new_sdk.py +10 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/requirements_already_new.txt +4 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/vertexai_generative_model.py +18 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/chat_history.before.py +21 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/configure_module_level.after.py +11 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/configure_module_level.before.py +12 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/embed_content.before.py +22 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/files_surface.after.py +28 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/files_surface.before.py +22 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/generate_content.before.py +30 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/import_alias.before.py +20 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/mock_patch_target.before.py +19 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/models_surface.after.py +13 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/models_surface.before.py +13 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/protos_import.before.py +21 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/requirements.after.txt +4 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/requirements.before.txt +4 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/tools_flagged.before.py +21 -0
- obelize/packs/gemini/google-generativeai-to-google-genai/pack.yaml +674 -0
- obelize/packs/loader.py +249 -0
- obelize/packs/schema.py +1194 -0
- obelize/providers/__init__.py +0 -0
- obelize/providers/base.py +363 -0
- obelize/providers/guard.py +496 -0
- obelize/providers/openai_compat.py +352 -0
- obelize/providers/proposals.py +421 -0
- obelize/py.typed +0 -0
- obelize/scan/__init__.py +0 -0
- obelize/scan/analysis.py +1049 -0
- obelize/scan/manifests.py +587 -0
- obelize/scan/parse.py +251 -0
- obelize/scan/reach.py +156 -0
- obelize/scan/runner.py +352 -0
- obelize/scan/runtime.py +190 -0
- obelize/scan/walker.py +242 -0
- obelize/schemas/__init__.py +1 -0
- obelize/schemas/findings.schema.json +348 -0
- obelize/schemas/generate.py +100 -0
- obelize/schemas/model.schema.json +124 -0
- obelize/schemas/pack.schema.json +1306 -0
- obelize/schemas/plan.schema.json +310 -0
- obelize/schemas/proposal.schema.json +294 -0
- obelize/schemas/run.schema.json +1074 -0
- obelize/schemas/undo.schema.json +130 -0
- obelize/transforms/__init__.py +0 -0
- obelize/transforms/base.py +276 -0
- obelize/transforms/codemod.py +410 -0
- obelize/transforms/imports.py +191 -0
- obelize/transforms/kinds/__init__.py +0 -0
- obelize/transforms/kinds/configure_to_client.py +322 -0
- obelize/transforms/kinds/flag_only.py +92 -0
- obelize/transforms/kinds/generative_model_calls.py +1215 -0
- obelize/transforms/kinds/manifest_dependency.py +125 -0
- obelize/transforms/kinds/rename_import.py +462 -0
- obelize/transforms/kinds/rewrite_call.py +267 -0
- obelize/transforms/layout.py +148 -0
- obelize/transforms/manifest.py +114 -0
- obelize/transforms/registry.py +74 -0
- obelize/transforms/tree.py +163 -0
- obelize/verify/__init__.py +0 -0
- obelize/verify/cheap.py +49 -0
- obelize/verify/redact.py +103 -0
- obelize/verify/runner.py +437 -0
- obelize/verify/status.py +89 -0
- obelize-0.1.0.dist-info/METADATA +282 -0
- obelize-0.1.0.dist-info/RECORD +115 -0
- obelize-0.1.0.dist-info/WHEEL +4 -0
- obelize-0.1.0.dist-info/entry_points.txt +2 -0
- obelize-0.1.0.dist-info/licenses/LICENSE +202 -0
- obelize-0.1.0.dist-info/licenses/NOTICE +10 -0
obelize/__init__.py
ADDED
obelize/__main__.py
ADDED
obelize/_yaml.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""YAML reading for `.obelize.yml` and `pack.yaml`, neither of which obelize writes.
|
|
2
|
+
|
|
3
|
+
`SafeLoader` only, so a file cannot construct Python objects, and a duplicate key is an error
|
|
4
|
+
instead of the last one silently winning. Errors carry a line but no file name; the caller adds it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DuplicateKeyError(ValueError):
|
|
15
|
+
"""A mapping that declares the same key twice, and where."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, key: str, line: int) -> None:
|
|
18
|
+
super().__init__(f"duplicate key {key!r}")
|
|
19
|
+
self.key = key
|
|
20
|
+
# 1-based.
|
|
21
|
+
self.line = line
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Loader(yaml.SafeLoader):
|
|
25
|
+
"""`yaml.SafeLoader`, minus the rule that the last duplicate key wins."""
|
|
26
|
+
|
|
27
|
+
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
28
|
+
seen: list[str] = []
|
|
29
|
+
for key_node, _ in node.value:
|
|
30
|
+
key = self.construct_object(key_node, deep=deep)
|
|
31
|
+
# Non-string keys are left to the model, which reports them as unknown.
|
|
32
|
+
if isinstance(key, str):
|
|
33
|
+
if key in seen:
|
|
34
|
+
raise DuplicateKeyError(key, key_node.start_mark.line + 1)
|
|
35
|
+
seen.append(key)
|
|
36
|
+
return super().construct_mapping(node, deep)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse(text: str) -> Any:
|
|
40
|
+
"""Parse one YAML document. Raises `DuplicateKeyError` or `yaml.YAMLError`."""
|
|
41
|
+
return yaml.load(text, Loader=_Loader) # noqa: S506 -- _Loader derives from SafeLoader
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def where(error: yaml.YAMLError) -> tuple[int, int, str] | None:
|
|
45
|
+
"""`(line, column, problem)`, 1-based, or None when the error carries no position."""
|
|
46
|
+
mark = getattr(error, "problem_mark", None)
|
|
47
|
+
problem = getattr(error, "problem", None)
|
|
48
|
+
if mark is None or problem is None:
|
|
49
|
+
return None
|
|
50
|
+
return mark.line + 1, mark.column + 1, str(problem)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["DuplicateKeyError", "parse", "where"]
|
obelize/cli.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Startup budget: `obelize --help` must stay well under 300 ms, so only typer is imported at module
|
|
4
|
+
scope; heavy imports (libcst, pydantic models, packs) go inside the command body that needs them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from obelize import __version__
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
from obelize.config import LoadedConfig, Overrides
|
|
24
|
+
from obelize.models import Config, ConfigOrigin, ModelConfig
|
|
25
|
+
from obelize.packs.loader import LoadedPack
|
|
26
|
+
from obelize.verify.runner import Mode
|
|
27
|
+
|
|
28
|
+
# A bundled pack failing validation exits 1, not 7: the input was obelize's own.
|
|
29
|
+
_BUNDLED_DEFECT = (
|
|
30
|
+
"This pack ships with obelize, so this is a bug in obelize. "
|
|
31
|
+
"Please report it at https://github.com/hakanbogan/obelize/issues."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="obelize",
|
|
36
|
+
help="Migrate Python code across external SDK versions, with evidence. "
|
|
37
|
+
"Supported migration: google-generativeai to google-genai.",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
add_completion=False,
|
|
40
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version_callback(value: bool) -> None:
|
|
45
|
+
if value:
|
|
46
|
+
typer.echo(f"obelize {__version__}")
|
|
47
|
+
raise typer.Exit(0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def colour_is_disabled(argv: Sequence[str], env: Mapping[str, str]) -> bool:
|
|
51
|
+
"""Decide whether to strip colour, before anything has been rendered.
|
|
52
|
+
|
|
53
|
+
Only leading options count: a `--no-color` inside `--verify` is another tool's. Any non-empty
|
|
54
|
+
`NO_COLOR` disables it, even `0`, per https://no-color.org (typer's `envvar=` reads `0` as on).
|
|
55
|
+
"""
|
|
56
|
+
for argument in argv:
|
|
57
|
+
if not argument.startswith("-"):
|
|
58
|
+
break
|
|
59
|
+
if argument == "--no-color":
|
|
60
|
+
return True
|
|
61
|
+
return bool(env.get("NO_COLOR"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def disable_colour() -> None:
|
|
65
|
+
"""Turn off the colour in typer's own help and error rendering.
|
|
66
|
+
|
|
67
|
+
typer's console ignores `NO_COLOR` and eager `--help` exits before any callback runs, so this
|
|
68
|
+
must precede `app()`. The import stays lazy for the startup budget.
|
|
69
|
+
"""
|
|
70
|
+
from typer import rich_utils
|
|
71
|
+
|
|
72
|
+
rich_utils.COLOR_SYSTEM = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Named, not discovered: "the only bundled pack" would change meaning once a second one ships.
|
|
76
|
+
DEFAULT_PACK = "gemini/google-generativeai-to-google-genai"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("scan")
|
|
80
|
+
def scan(
|
|
81
|
+
repo: str = typer.Option(
|
|
82
|
+
".",
|
|
83
|
+
"--repo",
|
|
84
|
+
metavar="<path>",
|
|
85
|
+
help="Repository root to scan. Defaults to the current directory.",
|
|
86
|
+
),
|
|
87
|
+
pack: str = typer.Option(
|
|
88
|
+
DEFAULT_PACK,
|
|
89
|
+
"--pack",
|
|
90
|
+
metavar="<id|path>",
|
|
91
|
+
help="A bundled pack id, or a path to a pack file.",
|
|
92
|
+
),
|
|
93
|
+
as_json: bool = typer.Option(
|
|
94
|
+
False,
|
|
95
|
+
"--json",
|
|
96
|
+
help="Print the findings as JSON instead of the table. "
|
|
97
|
+
"Two runs on the same input print the same bytes.",
|
|
98
|
+
),
|
|
99
|
+
jobs: int | None = typer.Option(
|
|
100
|
+
None,
|
|
101
|
+
"--jobs",
|
|
102
|
+
metavar="N",
|
|
103
|
+
help="Number of worker processes. The default is one fewer than the CPU "
|
|
104
|
+
"count, at most 8, with a small repository scanned in-process; a number you "
|
|
105
|
+
"give is always used. The number does not change the output.",
|
|
106
|
+
),
|
|
107
|
+
list_files: bool = typer.Option(
|
|
108
|
+
False,
|
|
109
|
+
"--list-files",
|
|
110
|
+
help="Print the files selected for the scan, in order, and stop.",
|
|
111
|
+
),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Find the call sites, imports and manifest entries a pack affects.
|
|
114
|
+
|
|
115
|
+
Edits no source file. Evidence goes to .obelize/runs/<id>/, which every
|
|
116
|
+
scan excludes.
|
|
117
|
+
"""
|
|
118
|
+
from obelize import gitutil
|
|
119
|
+
from obelize.config import ConfigError
|
|
120
|
+
from obelize.evidence import report, run_dir
|
|
121
|
+
from obelize.models import FindingsDocument, PackRef
|
|
122
|
+
from obelize.packs import loader
|
|
123
|
+
from obelize.scan import runner, walker
|
|
124
|
+
|
|
125
|
+
root = Path(repo)
|
|
126
|
+
loaded = _pack_or_exit(pack)
|
|
127
|
+
started = run_dir.now()
|
|
128
|
+
began = time.monotonic()
|
|
129
|
+
try:
|
|
130
|
+
# Before anything is read, so `--jobs 0` is refused before the repository is hashed.
|
|
131
|
+
runner.worker_count(jobs)
|
|
132
|
+
configuration = _configuration(root)
|
|
133
|
+
if list_files:
|
|
134
|
+
for path in walker.walk(root, configuration.config).files:
|
|
135
|
+
typer.echo(path)
|
|
136
|
+
return
|
|
137
|
+
result = runner.scan(root, configuration.config, loader.to_scan_spec(loaded), jobs=jobs)
|
|
138
|
+
except ConfigError as error:
|
|
139
|
+
typer.echo(str(error), err=True)
|
|
140
|
+
raise typer.Exit(2) from error
|
|
141
|
+
scan_ms = int((time.monotonic() - began) * 1000)
|
|
142
|
+
|
|
143
|
+
document = FindingsDocument(
|
|
144
|
+
obelize_version=__version__,
|
|
145
|
+
pack=PackRef(id=loaded.pack.id, version=loaded.pack.pack_version, sha256=loaded.sha256),
|
|
146
|
+
counts=result.counts,
|
|
147
|
+
findings=result.findings,
|
|
148
|
+
)
|
|
149
|
+
findings = document.model_dump_json(indent=2) + "\n"
|
|
150
|
+
record = run_dir.compose(
|
|
151
|
+
run_id=run_dir.new_id(started),
|
|
152
|
+
scan=result,
|
|
153
|
+
pack=loaded,
|
|
154
|
+
config=configuration.config,
|
|
155
|
+
source=configuration.source,
|
|
156
|
+
git=gitutil.state(root),
|
|
157
|
+
argv=tuple(sys.argv[1:]),
|
|
158
|
+
started=started,
|
|
159
|
+
finished=run_dir.now(),
|
|
160
|
+
total_ms=int((time.monotonic() - began) * 1000),
|
|
161
|
+
scan_ms=scan_ms,
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
written = run_dir.write(
|
|
165
|
+
root, record, findings, loaded.data, report.document(record, result)
|
|
166
|
+
)
|
|
167
|
+
except run_dir.EvidenceError as error:
|
|
168
|
+
typer.echo(str(error), err=True)
|
|
169
|
+
raise typer.Exit(1) from error
|
|
170
|
+
|
|
171
|
+
evidence = _under(repo, written.relative)
|
|
172
|
+
if as_json:
|
|
173
|
+
_document(findings)
|
|
174
|
+
# On stderr, so stdout stays the document; `obelize verify --run <id>` needs the id.
|
|
175
|
+
typer.echo(f"Evidence: {evidence}", err=True)
|
|
176
|
+
return
|
|
177
|
+
named = f"{loaded.pack.id} {loaded.pack.pack_version}"
|
|
178
|
+
for line in report.terminal(result, named, evidence):
|
|
179
|
+
typer.echo(line)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("fix")
|
|
183
|
+
def fix(
|
|
184
|
+
pack: str = typer.Option(
|
|
185
|
+
DEFAULT_PACK,
|
|
186
|
+
"--pack",
|
|
187
|
+
metavar="<id|path>",
|
|
188
|
+
help="A bundled pack id, or a path to a pack file.",
|
|
189
|
+
),
|
|
190
|
+
repo: str = typer.Option(
|
|
191
|
+
".",
|
|
192
|
+
"--repo",
|
|
193
|
+
metavar="<path>",
|
|
194
|
+
help="Repository root. Defaults to the current directory.",
|
|
195
|
+
),
|
|
196
|
+
apply_changes: bool = typer.Option(
|
|
197
|
+
False,
|
|
198
|
+
"--apply",
|
|
199
|
+
help="Write the planned edits. Without it nothing in the repository is touched.",
|
|
200
|
+
),
|
|
201
|
+
allow_dirty: bool = typer.Option(
|
|
202
|
+
False,
|
|
203
|
+
"--allow-dirty",
|
|
204
|
+
help="Allow --apply when a tracked file has uncommitted changes or a file it "
|
|
205
|
+
"would edit is not tracked by git.",
|
|
206
|
+
),
|
|
207
|
+
verify_commands: list[str] | None = typer.Option(
|
|
208
|
+
None,
|
|
209
|
+
"--verify",
|
|
210
|
+
metavar='"<cmd>"',
|
|
211
|
+
help="A verification command to run, such as your tests. Repeatable. Replaces "
|
|
212
|
+
"the commands in .obelize.yml, and runs without asking.",
|
|
213
|
+
),
|
|
214
|
+
timeout: int | None = typer.Option(
|
|
215
|
+
None,
|
|
216
|
+
"--timeout",
|
|
217
|
+
metavar="<s>",
|
|
218
|
+
min=1,
|
|
219
|
+
help="Per-command timeout, overriding verify.timeout_s for this run.",
|
|
220
|
+
),
|
|
221
|
+
non_interactive: bool = typer.Option(
|
|
222
|
+
False,
|
|
223
|
+
"--non-interactive",
|
|
224
|
+
help="Never prompt. A command that would need your approval is refused instead.",
|
|
225
|
+
),
|
|
226
|
+
trust_repo_config: bool = typer.Option(
|
|
227
|
+
False,
|
|
228
|
+
"--trust-repo-config",
|
|
229
|
+
help="Trust the verify.commands in this repository's .obelize.yml for this run.",
|
|
230
|
+
),
|
|
231
|
+
provider: str | None = typer.Option(
|
|
232
|
+
None,
|
|
233
|
+
"--model",
|
|
234
|
+
metavar="<provider>",
|
|
235
|
+
help="Model provider to ask about call sites the rules could not migrate: "
|
|
236
|
+
"none or openai_compat. Overrides model.provider.",
|
|
237
|
+
),
|
|
238
|
+
accept_model: bool = typer.Option(
|
|
239
|
+
False,
|
|
240
|
+
"--accept-model",
|
|
241
|
+
help="Also write the model's edits that passed validation. Requires --apply.",
|
|
242
|
+
),
|
|
243
|
+
show_context: bool = typer.Option(
|
|
244
|
+
False,
|
|
245
|
+
"--show-context",
|
|
246
|
+
help="Print what would be sent to a model and stop. Nothing is sent.",
|
|
247
|
+
),
|
|
248
|
+
as_json: bool = typer.Option(
|
|
249
|
+
False, "--json", help="Print the plan as JSON instead of the summary."
|
|
250
|
+
),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Build a migration plan from a pack and, with --apply, write it.
|
|
253
|
+
|
|
254
|
+
Dry run by default: the change is printed and saved as patch.diff in the run
|
|
255
|
+
folder. --apply needs a clean tree and runs, in order: your tests, the
|
|
256
|
+
write, a compile check, your tests again. If your tests fail before the
|
|
257
|
+
write, the change is still written and the tests are not run a second time.
|
|
258
|
+
"""
|
|
259
|
+
from obelize.commands import CommandError
|
|
260
|
+
from obelize.commands import fix as fixer
|
|
261
|
+
from obelize.config import ConfigError
|
|
262
|
+
from obelize.evidence import report
|
|
263
|
+
from obelize.providers import proposals
|
|
264
|
+
|
|
265
|
+
_model_flags(apply_changes, accept_model=accept_model, show_context=show_context)
|
|
266
|
+
root = Path(repo)
|
|
267
|
+
loaded = _pack_or_exit(pack)
|
|
268
|
+
try:
|
|
269
|
+
config, source = _settings(root, _overrides(verify_commands, timeout, allow_dirty))
|
|
270
|
+
model = _model(provider)
|
|
271
|
+
except ConfigError as error:
|
|
272
|
+
typer.echo(str(error), err=True)
|
|
273
|
+
raise typer.Exit(2) from error
|
|
274
|
+
request = fixer.Request(
|
|
275
|
+
root=root,
|
|
276
|
+
pack=loaded,
|
|
277
|
+
config=config,
|
|
278
|
+
source=source,
|
|
279
|
+
cli_commands=tuple(verify_commands or ()),
|
|
280
|
+
mode=_trust(non_interactive=non_interactive, trust_repo_config=trust_repo_config),
|
|
281
|
+
config_dir=_config_dir(),
|
|
282
|
+
environ=os.environ,
|
|
283
|
+
argv=tuple(sys.argv[1:]),
|
|
284
|
+
apply=apply_changes,
|
|
285
|
+
accept_model=accept_model,
|
|
286
|
+
ask=_ask,
|
|
287
|
+
model=model,
|
|
288
|
+
)
|
|
289
|
+
if show_context:
|
|
290
|
+
for line in fixer.context(request):
|
|
291
|
+
typer.echo(line)
|
|
292
|
+
raise typer.Exit(0)
|
|
293
|
+
if model.provider != "none":
|
|
294
|
+
# Announced before the scan, on stderr so `--json` stdout stays one document.
|
|
295
|
+
typer.echo(proposals.heading(model), err=True)
|
|
296
|
+
try:
|
|
297
|
+
outcome = fixer.run(request)
|
|
298
|
+
except CommandError as error:
|
|
299
|
+
typer.echo(str(error), err=True)
|
|
300
|
+
raise typer.Exit(error.code) from error
|
|
301
|
+
evidence = _under(repo, outcome.evidence)
|
|
302
|
+
_emit(
|
|
303
|
+
outcome.document if as_json else None,
|
|
304
|
+
evidence,
|
|
305
|
+
lambda: report.fixed(
|
|
306
|
+
outcome.record,
|
|
307
|
+
outcome.plan,
|
|
308
|
+
f"{loaded.pack.id} {loaded.pack.pack_version}",
|
|
309
|
+
evidence,
|
|
310
|
+
outcome.declared,
|
|
311
|
+
outcome.patch,
|
|
312
|
+
os.environ,
|
|
313
|
+
repo,
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
raise typer.Exit(outcome.exit_code)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@app.command("verify")
|
|
320
|
+
def verify(
|
|
321
|
+
run_id: str = typer.Option(
|
|
322
|
+
..., "--run", metavar="<id>", help="The run id under .obelize/runs/. Required."
|
|
323
|
+
),
|
|
324
|
+
repo: str = typer.Option(
|
|
325
|
+
".",
|
|
326
|
+
"--repo",
|
|
327
|
+
metavar="<path>",
|
|
328
|
+
help="Repository root. Defaults to the current directory.",
|
|
329
|
+
),
|
|
330
|
+
verify_commands: list[str] | None = typer.Option(
|
|
331
|
+
None,
|
|
332
|
+
"--verify",
|
|
333
|
+
metavar='"<cmd>"',
|
|
334
|
+
help="A verification command to run. Repeatable. Replaces the ones the "
|
|
335
|
+
"repository configures.",
|
|
336
|
+
),
|
|
337
|
+
timeout: int | None = typer.Option(
|
|
338
|
+
None,
|
|
339
|
+
"--timeout",
|
|
340
|
+
metavar="<s>",
|
|
341
|
+
min=1,
|
|
342
|
+
help="Per-command timeout, overriding verify.timeout_s for this run.",
|
|
343
|
+
),
|
|
344
|
+
non_interactive: bool = typer.Option(
|
|
345
|
+
False,
|
|
346
|
+
"--non-interactive",
|
|
347
|
+
help="Never prompt. A command that would need your approval is refused instead.",
|
|
348
|
+
),
|
|
349
|
+
trust_repo_config: bool = typer.Option(
|
|
350
|
+
False,
|
|
351
|
+
"--trust-repo-config",
|
|
352
|
+
help="Trust the verify.commands in this repository's .obelize.yml for this run.",
|
|
353
|
+
),
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Re-run the verification phase for an existing run and update its evidence.
|
|
356
|
+
|
|
357
|
+
If a file the run wrote has changed since, the result is inconclusive
|
|
358
|
+
(tree_changed) and nothing runs. Commands come from your configuration,
|
|
359
|
+
never the run folder, so the trust flags apply again.
|
|
360
|
+
"""
|
|
361
|
+
from obelize.commands import CommandError
|
|
362
|
+
from obelize.commands import verify as verifier
|
|
363
|
+
from obelize.config import ConfigError
|
|
364
|
+
from obelize.evidence import report
|
|
365
|
+
|
|
366
|
+
root = Path(repo)
|
|
367
|
+
try:
|
|
368
|
+
config, _ = _settings(root, _overrides(verify_commands, timeout, allow_dirty=False))
|
|
369
|
+
except ConfigError as error:
|
|
370
|
+
typer.echo(str(error), err=True)
|
|
371
|
+
raise typer.Exit(2) from error
|
|
372
|
+
try:
|
|
373
|
+
outcome = verifier.run(
|
|
374
|
+
verifier.Request(
|
|
375
|
+
root=root,
|
|
376
|
+
run_id=run_id,
|
|
377
|
+
config=config,
|
|
378
|
+
cli_commands=tuple(verify_commands or ()),
|
|
379
|
+
mode=_trust(non_interactive=non_interactive, trust_repo_config=trust_repo_config),
|
|
380
|
+
config_dir=_config_dir(),
|
|
381
|
+
environ=os.environ,
|
|
382
|
+
ask=_ask,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
except CommandError as error:
|
|
386
|
+
typer.echo(str(error), err=True)
|
|
387
|
+
raise typer.Exit(error.code) from error
|
|
388
|
+
except ConfigError as error:
|
|
389
|
+
# The user's allowlist, read only when the repository asks for a command.
|
|
390
|
+
typer.echo(str(error), err=True)
|
|
391
|
+
raise typer.Exit(2) from error
|
|
392
|
+
for line in report.verified(outcome.record, _under(repo, outcome.evidence)):
|
|
393
|
+
typer.echo(line)
|
|
394
|
+
if not outcome.rewritten:
|
|
395
|
+
typer.echo("Nothing was re-run, so nothing in the folder changed.")
|
|
396
|
+
raise typer.Exit(outcome.exit_code)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.command("undo")
|
|
400
|
+
def undo(
|
|
401
|
+
run_id: str = typer.Option(
|
|
402
|
+
..., "--run", metavar="<id>", help="The run id under .obelize/runs/. Required."
|
|
403
|
+
),
|
|
404
|
+
repo: str = typer.Option(
|
|
405
|
+
".",
|
|
406
|
+
"--repo",
|
|
407
|
+
metavar="<path>",
|
|
408
|
+
help="Repository root. Defaults to the current directory.",
|
|
409
|
+
),
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Revert the files an applied run wrote, checked against its own hashes.
|
|
412
|
+
|
|
413
|
+
A file edited since is skipped and reported with both hashes and its
|
|
414
|
+
snapshots/before/ copy. Obelize never overwrites content it did not
|
|
415
|
+
write, so there is no --force.
|
|
416
|
+
"""
|
|
417
|
+
from obelize.commands import CommandError
|
|
418
|
+
from obelize.commands import undo as reverter
|
|
419
|
+
from obelize.config import ConfigError
|
|
420
|
+
from obelize.evidence import report
|
|
421
|
+
|
|
422
|
+
root = Path(repo)
|
|
423
|
+
try:
|
|
424
|
+
_configuration(root)
|
|
425
|
+
except ConfigError as error:
|
|
426
|
+
typer.echo(str(error), err=True)
|
|
427
|
+
raise typer.Exit(2) from error
|
|
428
|
+
try:
|
|
429
|
+
outcome = reverter.run(reverter.Request(root=root, run_id=run_id, argv=tuple(sys.argv[1:])))
|
|
430
|
+
except CommandError as error:
|
|
431
|
+
typer.echo(str(error), err=True)
|
|
432
|
+
raise typer.Exit(error.code) from error
|
|
433
|
+
for line in report.reverted(
|
|
434
|
+
outcome.record, _under(repo, outcome.folder), _under(repo, outcome.evidence)
|
|
435
|
+
):
|
|
436
|
+
typer.echo(line)
|
|
437
|
+
raise typer.Exit(outcome.exit_code)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _under(repo: str, relative: str) -> str:
|
|
441
|
+
"""A repository-relative path under `--repo` as typed, so it opens from where the user is."""
|
|
442
|
+
return str(Path(repo) / relative)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _document(text: str) -> None:
|
|
446
|
+
"""A `--json` document, as the UTF-8 bytes the run folder holds.
|
|
447
|
+
|
|
448
|
+
Bytes skip the text layer, which on Windows encodes in the ANSI code page and ends each line
|
|
449
|
+
with CRLF; click flushes that layer first.
|
|
450
|
+
"""
|
|
451
|
+
typer.echo(text.encode("utf-8"), nl=False)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _emit(document: str | None, evidence: str, lines: Callable[[], list[str]]) -> None:
|
|
455
|
+
"""The `--json` document (evidence path on stderr), or the summary."""
|
|
456
|
+
if document is not None:
|
|
457
|
+
_document(document)
|
|
458
|
+
typer.echo(f"Evidence: {evidence}", err=True)
|
|
459
|
+
return
|
|
460
|
+
for line in lines():
|
|
461
|
+
typer.echo(line)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _overrides(commands: list[str] | None, timeout: int | None, allow_dirty: bool) -> Overrides:
|
|
465
|
+
from obelize import config as configuration
|
|
466
|
+
|
|
467
|
+
return configuration.flags(commands or (), timeout, allow_dirty=allow_dirty)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _model(provider: str | None) -> ModelConfig:
|
|
471
|
+
"""The user's own `model:` block with `--model` over it."""
|
|
472
|
+
from obelize import config as configuration
|
|
473
|
+
from obelize.verify.runner import ALLOWLIST_FILENAME, user_config
|
|
474
|
+
|
|
475
|
+
home = _config_dir()
|
|
476
|
+
return configuration.model(user_config(home).model, provider, home / ALLOWLIST_FILENAME)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _model_flags(apply_changes: bool, *, accept_model: bool, show_context: bool) -> None:
|
|
480
|
+
"""Refuse contradictory model flags before any pack load or scan."""
|
|
481
|
+
if accept_model and not apply_changes:
|
|
482
|
+
typer.echo(
|
|
483
|
+
"--accept-model requires --apply. Leave it out to see the model's proposals "
|
|
484
|
+
"without writing them.",
|
|
485
|
+
err=True,
|
|
486
|
+
)
|
|
487
|
+
raise typer.Exit(2)
|
|
488
|
+
if show_context and apply_changes:
|
|
489
|
+
typer.echo(
|
|
490
|
+
"--show-context cannot be combined with --apply: it only prints what would be sent.",
|
|
491
|
+
err=True,
|
|
492
|
+
)
|
|
493
|
+
raise typer.Exit(2)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _settings(root: Path, overrides: Overrides) -> tuple[Config, ConfigOrigin]:
|
|
497
|
+
"""`.obelize.yml` with the command line over it, and where the file was."""
|
|
498
|
+
from obelize import config as configuration
|
|
499
|
+
|
|
500
|
+
loaded = _configuration(root)
|
|
501
|
+
return configuration.merge(loaded.config, overrides), loaded.source
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _trust(*, non_interactive: bool, trust_repo_config: bool) -> Mode:
|
|
505
|
+
"""This invocation as the trust ladder sees it; the only place the terminal is asked."""
|
|
506
|
+
from obelize.verify.runner import Mode
|
|
507
|
+
|
|
508
|
+
return Mode.of(
|
|
509
|
+
os.environ,
|
|
510
|
+
non_interactive=non_interactive,
|
|
511
|
+
trust_repo_config=trust_repo_config,
|
|
512
|
+
tty=sys.stdin.isatty(),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _config_dir() -> Path:
|
|
517
|
+
"""`~/.config/obelize`, or wherever `XDG_CONFIG_HOME` puts it."""
|
|
518
|
+
from obelize.verify.runner import config_home
|
|
519
|
+
|
|
520
|
+
return config_home(os.environ)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _ask(command: str) -> bool:
|
|
524
|
+
"""Ask once whether a repository's command may run; a yes is remembered for that command."""
|
|
525
|
+
typer.echo(f"This repository asks to run: {command}")
|
|
526
|
+
return typer.confirm("Run it, and remember the answer for this repository?", default=False)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _configuration(root: Path) -> LoadedConfig:
|
|
530
|
+
"""`.obelize.yml` under `root`; a non-directory root is reported against `--repo`."""
|
|
531
|
+
from obelize import config as configuration
|
|
532
|
+
from obelize.config import ConfigError
|
|
533
|
+
|
|
534
|
+
if not root.is_dir():
|
|
535
|
+
raise ConfigError(f"--repo {root} is not a directory, so there is nothing to scan")
|
|
536
|
+
return configuration.load(root)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _pack_or_exit(reference: str) -> LoadedPack:
|
|
540
|
+
"""Load a pack, or exit 2 / 7 / 1 exactly as `obelize pack validate` does."""
|
|
541
|
+
from obelize.packs import loader
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
return loader.load(reference)
|
|
545
|
+
except loader.PackNotFoundError as error:
|
|
546
|
+
typer.echo(str(error), err=True)
|
|
547
|
+
raise typer.Exit(2) from error
|
|
548
|
+
except loader.PackInvalidError as error:
|
|
549
|
+
typer.echo(str(error), err=True)
|
|
550
|
+
if error.bundled:
|
|
551
|
+
typer.echo(_BUNDLED_DEFECT, err=True)
|
|
552
|
+
raise typer.Exit(1) from error
|
|
553
|
+
raise typer.Exit(7) from error
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
pack_app = typer.Typer(
|
|
557
|
+
name="pack",
|
|
558
|
+
help="Inspect migration packs.",
|
|
559
|
+
no_args_is_help=True,
|
|
560
|
+
)
|
|
561
|
+
app.add_typer(pack_app)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@pack_app.command("validate")
|
|
565
|
+
def pack_validate(
|
|
566
|
+
reference: str = typer.Argument(
|
|
567
|
+
...,
|
|
568
|
+
metavar="<id|path>",
|
|
569
|
+
help="A bundled pack id, or a path to a pack file.",
|
|
570
|
+
),
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Validate a migration pack against the pack format.
|
|
573
|
+
|
|
574
|
+
Errors name the path of each invalid key. Exit codes: 2 pack not found,
|
|
575
|
+
7 pack invalid, 1 bundled pack invalid (a bug in obelize).
|
|
576
|
+
|
|
577
|
+
The format is described at https://github.com/hakanbogan/obelize/blob/main/docs/PACK_SPEC.md
|
|
578
|
+
"""
|
|
579
|
+
from obelize.packs import loader
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
loaded = loader.load(reference)
|
|
583
|
+
except loader.PackNotFoundError as error:
|
|
584
|
+
typer.echo(str(error), err=True)
|
|
585
|
+
raise typer.Exit(2) from error
|
|
586
|
+
except loader.PackInvalidError as error:
|
|
587
|
+
typer.echo(str(error), err=True)
|
|
588
|
+
if error.bundled:
|
|
589
|
+
typer.echo(_BUNDLED_DEFECT, err=True)
|
|
590
|
+
raise typer.Exit(1) from error
|
|
591
|
+
raise typer.Exit(7) from error
|
|
592
|
+
|
|
593
|
+
pack = loaded.pack
|
|
594
|
+
typer.echo(f"{pack.id} {pack.pack_version} is a valid migration pack.")
|
|
595
|
+
typer.echo(f" sha256 {loaded.sha256}")
|
|
596
|
+
typer.echo(
|
|
597
|
+
f" migrates {pack.from_.package} {pack.from_.version} -> "
|
|
598
|
+
f"{pack.to.package} {pack.to.version}"
|
|
599
|
+
)
|
|
600
|
+
typer.echo(f" changes {len(pack.changes)}")
|
|
601
|
+
typer.echo(f" limitations {len(pack.limitations)}")
|
|
602
|
+
# A local pack may ship without fixtures; tests/packs/ checks the bundled ones.
|
|
603
|
+
typer.echo(" fixtures not checked by this command")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.callback()
|
|
607
|
+
def main_callback(
|
|
608
|
+
version: bool = typer.Option(
|
|
609
|
+
False,
|
|
610
|
+
"--version",
|
|
611
|
+
help="Show the obelize version and exit.",
|
|
612
|
+
callback=_version_callback,
|
|
613
|
+
is_eager=True,
|
|
614
|
+
),
|
|
615
|
+
no_color: bool = typer.Option(
|
|
616
|
+
False,
|
|
617
|
+
"--no-color",
|
|
618
|
+
help="Disable coloured output. Also honoured as the NO_COLOR environment variable.",
|
|
619
|
+
),
|
|
620
|
+
) -> None: ...
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def main() -> None:
|
|
624
|
+
"""Console-script entry point."""
|
|
625
|
+
from obelize.native import console
|
|
626
|
+
|
|
627
|
+
console.utf8_stdio()
|
|
628
|
+
if colour_is_disabled(sys.argv[1:], os.environ):
|
|
629
|
+
disable_colour()
|
|
630
|
+
app()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__": # pragma: no cover
|
|
634
|
+
main()
|