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.
Files changed (115) hide show
  1. obelize/__init__.py +5 -0
  2. obelize/__main__.py +5 -0
  3. obelize/_yaml.py +53 -0
  4. obelize/cli.py +634 -0
  5. obelize/commands/__init__.py +29 -0
  6. obelize/commands/fix.py +458 -0
  7. obelize/commands/undo.py +141 -0
  8. obelize/commands/verify.py +168 -0
  9. obelize/config.py +335 -0
  10. obelize/evidence/__init__.py +0 -0
  11. obelize/evidence/folder.py +125 -0
  12. obelize/evidence/patch.py +161 -0
  13. obelize/evidence/report.py +723 -0
  14. obelize/evidence/run_dir.py +863 -0
  15. obelize/fsutil.py +333 -0
  16. obelize/gitutil.py +133 -0
  17. obelize/impact/__init__.py +0 -0
  18. obelize/impact/dataflow.py +106 -0
  19. obelize/impact/planner.py +203 -0
  20. obelize/models.py +2065 -0
  21. obelize/native/__init__.py +1 -0
  22. obelize/native/_win32.py +683 -0
  23. obelize/native/console.py +10 -0
  24. obelize/native/console_posix.py +14 -0
  25. obelize/native/console_windows.py +21 -0
  26. obelize/native/files.py +61 -0
  27. obelize/native/files_posix.py +94 -0
  28. obelize/native/files_windows.py +263 -0
  29. obelize/native/processes.py +49 -0
  30. obelize/native/processes_posix.py +101 -0
  31. obelize/native/processes_windows.py +161 -0
  32. obelize/native/shell.py +10 -0
  33. obelize/native/windows_names.py +38 -0
  34. obelize/native/windows_programs.py +125 -0
  35. obelize/packs/__init__.py +0 -0
  36. obelize/packs/gemini/google-generativeai-to-google-genai/SOURCES.md +183 -0
  37. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/already_new.py +17 -0
  38. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/chat_history_new_sdk.py +17 -0
  39. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/configure_new_sdk_client.py +11 -0
  40. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/embed_content_new_sdk.py +15 -0
  41. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/files_surface_new_sdk.py +27 -0
  42. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/local_genai_package.py +9 -0
  43. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/models_surface_new_sdk.py +13 -0
  44. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/no_out_of_scope_surface.py +16 -0
  45. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/no_tools.py +21 -0
  46. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/patch_new_sdk.py +10 -0
  47. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/requirements_already_new.txt +4 -0
  48. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/negative/vertexai_generative_model.py +18 -0
  49. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/chat_history.before.py +21 -0
  50. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/configure_module_level.after.py +11 -0
  51. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/configure_module_level.before.py +12 -0
  52. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/embed_content.before.py +22 -0
  53. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/files_surface.after.py +28 -0
  54. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/files_surface.before.py +22 -0
  55. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/generate_content.before.py +30 -0
  56. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/import_alias.before.py +20 -0
  57. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/mock_patch_target.before.py +19 -0
  58. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/models_surface.after.py +13 -0
  59. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/models_surface.before.py +13 -0
  60. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/protos_import.before.py +21 -0
  61. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/requirements.after.txt +4 -0
  62. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/requirements.before.txt +4 -0
  63. obelize/packs/gemini/google-generativeai-to-google-genai/fixtures/positive/tools_flagged.before.py +21 -0
  64. obelize/packs/gemini/google-generativeai-to-google-genai/pack.yaml +674 -0
  65. obelize/packs/loader.py +249 -0
  66. obelize/packs/schema.py +1194 -0
  67. obelize/providers/__init__.py +0 -0
  68. obelize/providers/base.py +363 -0
  69. obelize/providers/guard.py +496 -0
  70. obelize/providers/openai_compat.py +352 -0
  71. obelize/providers/proposals.py +421 -0
  72. obelize/py.typed +0 -0
  73. obelize/scan/__init__.py +0 -0
  74. obelize/scan/analysis.py +1049 -0
  75. obelize/scan/manifests.py +587 -0
  76. obelize/scan/parse.py +251 -0
  77. obelize/scan/reach.py +156 -0
  78. obelize/scan/runner.py +352 -0
  79. obelize/scan/runtime.py +190 -0
  80. obelize/scan/walker.py +242 -0
  81. obelize/schemas/__init__.py +1 -0
  82. obelize/schemas/findings.schema.json +348 -0
  83. obelize/schemas/generate.py +100 -0
  84. obelize/schemas/model.schema.json +124 -0
  85. obelize/schemas/pack.schema.json +1306 -0
  86. obelize/schemas/plan.schema.json +310 -0
  87. obelize/schemas/proposal.schema.json +294 -0
  88. obelize/schemas/run.schema.json +1074 -0
  89. obelize/schemas/undo.schema.json +130 -0
  90. obelize/transforms/__init__.py +0 -0
  91. obelize/transforms/base.py +276 -0
  92. obelize/transforms/codemod.py +410 -0
  93. obelize/transforms/imports.py +191 -0
  94. obelize/transforms/kinds/__init__.py +0 -0
  95. obelize/transforms/kinds/configure_to_client.py +322 -0
  96. obelize/transforms/kinds/flag_only.py +92 -0
  97. obelize/transforms/kinds/generative_model_calls.py +1215 -0
  98. obelize/transforms/kinds/manifest_dependency.py +125 -0
  99. obelize/transforms/kinds/rename_import.py +462 -0
  100. obelize/transforms/kinds/rewrite_call.py +267 -0
  101. obelize/transforms/layout.py +148 -0
  102. obelize/transforms/manifest.py +114 -0
  103. obelize/transforms/registry.py +74 -0
  104. obelize/transforms/tree.py +163 -0
  105. obelize/verify/__init__.py +0 -0
  106. obelize/verify/cheap.py +49 -0
  107. obelize/verify/redact.py +103 -0
  108. obelize/verify/runner.py +437 -0
  109. obelize/verify/status.py +89 -0
  110. obelize-0.1.0.dist-info/METADATA +282 -0
  111. obelize-0.1.0.dist-info/RECORD +115 -0
  112. obelize-0.1.0.dist-info/WHEEL +4 -0
  113. obelize-0.1.0.dist-info/entry_points.txt +2 -0
  114. obelize-0.1.0.dist-info/licenses/LICENSE +202 -0
  115. obelize-0.1.0.dist-info/licenses/NOTICE +10 -0
obelize/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Obelize, a local-first SDK/API migration CLI. `__version__` is hatchling's single source."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
obelize/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m obelize`, for where the console script is not on PATH."""
2
+
3
+ from obelize.cli import main
4
+
5
+ main()
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()