lastorders 0.4.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.
- lastorders-0.4.0.dist-info/METADATA +212 -0
- lastorders-0.4.0.dist-info/RECORD +27 -0
- lastorders-0.4.0.dist-info/WHEEL +5 -0
- lastorders-0.4.0.dist-info/entry_points.txt +2 -0
- lastorders-0.4.0.dist-info/top_level.txt +1 -0
- migration_kit/__init__.py +2 -0
- migration_kit/__main__.py +3 -0
- migration_kit/brand.py +27 -0
- migration_kit/cli.py +365 -0
- migration_kit/evaluator.py +526 -0
- migration_kit/fixer/__init__.py +12 -0
- migration_kit/fixer/callsite.py +65 -0
- migration_kit/fixer/engine.py +463 -0
- migration_kit/fixer/jsfix.py +373 -0
- migration_kit/fixer/pyfix.py +260 -0
- migration_kit/fixer/verify.py +292 -0
- migration_kit/initevals.py +278 -0
- migration_kit/licence.py +337 -0
- migration_kit/pack_data/__init__.py +0 -0
- migration_kit/pack_data/anthropic.json +271 -0
- migration_kit/pack_data/openai.json +1230 -0
- migration_kit/pack_data/pack.schema.json +615 -0
- migration_kit/packs.py +299 -0
- migration_kit/report.py +355 -0
- migration_kit/scanner.py +398 -0
- migration_kit/site.py +60 -0
- migration_kit/swapper.py +127 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lastorders
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Free scanner for retiring AI model IDs (OpenAI, Anthropic, ...) in your code, with CI support.
|
|
5
|
+
Author: Burnt & Toast Studios
|
|
6
|
+
Project-URL: Homepage, https://burntandtoast.com
|
|
7
|
+
Keywords: openai,anthropic,llm,deprecation,migration,gpt-4o,scanner,github-action
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: pyyaml>=6
|
|
20
|
+
Requires-Dist: jsonschema>=4
|
|
21
|
+
Requires-Dist: libcst>=1.1
|
|
22
|
+
Requires-Dist: cryptography>=41
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# Last Orders (working name)
|
|
28
|
+
|
|
29
|
+
**Find retiring AI model IDs in your code before they break.** One command scans your repo for OpenAI
|
|
30
|
+
(and, as a preview, Anthropic) model IDs that are being shut down. For each one it tells you the
|
|
31
|
+
shutdown date, the replacement the provider recommends, and the code changes the new model needs.
|
|
32
|
+
|
|
33
|
+
> **Heads-up:** OpenAI removes **GPT-4o, GPT-4o mini, GPT-4.1 and GPT-4.1 mini** from the API on
|
|
34
|
+
> **14 January 2027** and recommends `gpt-6-luna`. In our tests, `gpt-6-luna` rejects `max_tokens`, and it
|
|
35
|
+
> rejects tool calls on Chat Completions unless `reasoning_effort` is `"none"`. So changing the model name
|
|
36
|
+
> isn't enough. The scan flags both.
|
|
37
|
+
|
|
38
|
+
The scan is free. You don't need an account, a licence or an API key, and your code never leaves your machine.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install lastorders # or: pip install lastorders
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
You need Python 3.10 or later.
|
|
47
|
+
|
|
48
|
+
## Scan
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
lastorders scan . # scan the current repo
|
|
52
|
+
lastorders scan path/to/repo --out scan-results
|
|
53
|
+
lastorders scan . --offline # no network at all (uses the bundled migration pack)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You get `scan.md`, a readable report, and `scan.json`, the same data for tools. They go in `lastorders-out/`, or wherever `--out` points. Each finding includes:
|
|
57
|
+
|
|
58
|
+
- **File, line and model ID**, from code (Python, JS, TS), `.env` files, YAML, JSON, TOML and docs
|
|
59
|
+
- **Shutdown date and recommended replacement**, from the provider's published deprecations
|
|
60
|
+
- **Risk level**. High means the date is close or has passed, or the new model needs a code change your
|
|
61
|
+
code doesn't have yet, such as `max_tokens` becoming `max_completion_tokens`, `reasoning_effort` for tools
|
|
62
|
+
and `temperature`, or an unsupported endpoint
|
|
63
|
+
- **Fine-tuned models** (`ft:...`), listed separately. They shut down with their base model and can't be
|
|
64
|
+
swapped automatically.
|
|
65
|
+
- **Models chosen by environment variable** (`os.getenv("OPENAI_MODEL")`), so you can check the
|
|
66
|
+
values you have deployed
|
|
67
|
+
- **SDKs detected**: the official `openai` and `anthropic` clients, LangChain and plain HTTP
|
|
68
|
+
|
|
69
|
+
Useful flags:
|
|
70
|
+
|
|
71
|
+
| Flag | What it does |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `--fail-on-high` | exit code 2 if anything is high risk (for CI) |
|
|
74
|
+
| `--no-docs` | skip `.md` / `.txt` files |
|
|
75
|
+
| `--pack openai` | only use some providers' packs (default: all) |
|
|
76
|
+
| `--offline` | use the bundled pack only; makes no network calls |
|
|
77
|
+
| `--format json` | print the summary as JSON |
|
|
78
|
+
| `--summary-file F` / `--annotations` | CI outputs (used by the GitHub Action) |
|
|
79
|
+
|
|
80
|
+
## Fix (patch preview)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
lastorders fix . # writes lastorders-out/fix.diff + fix-plan.md; changes nothing on disk
|
|
84
|
+
lastorders fix . --verify # also checks the patch on a scratch copy (see below)
|
|
85
|
+
git apply lastorders-out/fix.diff
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`fix` swaps the retiring model IDs and then makes the code changes the new model needs at each API call:
|
|
89
|
+
|
|
90
|
+
- **Renames `max_tokens`**, to `max_completion_tokens` on Chat Completions or `max_output_tokens` on Responses.
|
|
91
|
+
- **Adds `reasoning_effort: "none"`** where gpt-6-luna needs it: Chat Completions calls with tools, and
|
|
92
|
+
calls that set `temperature`, `top_p` or `logprobs`. On Responses it adds `reasoning.effort`.
|
|
93
|
+
- **Flags anything it can't decide safely** in `fix-plan.md`: legacy `functions` calls, Assistants and legacy
|
|
94
|
+
Completions calls (these stay on their current model in the patch), fine-tunes and fine-tuning jobs,
|
|
95
|
+
LangChain wrappers, calls that pass `**kwargs` or a spread object, conflicting values, and calls whose model
|
|
96
|
+
isn't visible in the code (use `--assume-model`).
|
|
97
|
+
|
|
98
|
+
Every change comes from the migration pack's rules. Python is edited with
|
|
99
|
+
[libcst](https://github.com/Instagram/LibCST), so your formatting and comments are kept. JS and TS are edited
|
|
100
|
+
with a built-in parser. Rules the pack marks `inferred` are only suggested unless you pass `--include-inferred`.
|
|
101
|
+
|
|
102
|
+
**Old models reject `reasoning_effort`.** We tested gpt-4o and gpt-4.1 against the live API, and both return
|
|
103
|
+
HTTP 400. Any fix that adds it has to ship together with the model switch. When the model comes from an
|
|
104
|
+
environment variable, the plan marks the fix "ship with the model switch". `--no-model-swap` applies only
|
|
105
|
+
the changes that are safe on the old model.
|
|
106
|
+
|
|
107
|
+
`--verify` applies the patch to a temporary copy of your repo, then:
|
|
108
|
+
|
|
109
|
+
1. checks the changed files still parse;
|
|
110
|
+
2. re-runs the fixer and checks it changes nothing more;
|
|
111
|
+
3. replays each fixed call twice: once as it was (old model, old arguments) and once as patched (new model,
|
|
112
|
+
new arguments).
|
|
113
|
+
|
|
114
|
+
Without `--live`, the replay uses simulated responses. With `--live` and your own `OPENAI_API_KEY` (licence),
|
|
115
|
+
the calls go straight to OpenAI. A control run also sends the unpatched arguments to the new model, to show
|
|
116
|
+
each fix was needed. `--evals evals/regression.yaml` reruns your own suite with the fixed arguments.
|
|
117
|
+
|
|
118
|
+
## GitHub Action
|
|
119
|
+
|
|
120
|
+
Add `.github/workflows/model-scan.yml`:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
name: Model retirement scan
|
|
124
|
+
on:
|
|
125
|
+
pull_request:
|
|
126
|
+
schedule: [{ cron: "0 7 * * 1" }] # weekly, so new deprecations show up without code changes
|
|
127
|
+
permissions:
|
|
128
|
+
contents: read
|
|
129
|
+
jobs:
|
|
130
|
+
scan:
|
|
131
|
+
runs-on: ubuntu-latest
|
|
132
|
+
steps:
|
|
133
|
+
- uses: actions/checkout@v4
|
|
134
|
+
- uses: burntandtoast/lastorders@v0 # available once the repo is published
|
|
135
|
+
with:
|
|
136
|
+
fail-on-high: "true"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The Action posts a summary table to the job page, marks findings inline on the PR and uploads `scan.json`
|
|
140
|
+
as an artifact. Inputs:
|
|
141
|
+
|
|
142
|
+
| Input | Default | |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `path` | `.` | folder to scan |
|
|
145
|
+
| `fail-on-high` | `false` | fail the job on high-risk findings |
|
|
146
|
+
| `include-docs` | `true` | scan `.md`/`.txt` |
|
|
147
|
+
| `annotations` | `true` | inline PR annotations |
|
|
148
|
+
| `packs` | all | e.g. `openai,anthropic` |
|
|
149
|
+
| `offline` | `false` | bundled pack only, no network |
|
|
150
|
+
| `packs-url` | built-in | override the pack URL |
|
|
151
|
+
| `version` | action source | install this version from PyPI instead |
|
|
152
|
+
| `python-version` | `3.12` | |
|
|
153
|
+
| `fix-preview` | `false` | also attach the automatic-fix patch + plan (simulated verify, no key) |
|
|
154
|
+
| `artifact-name` | `lastorders-scan` | |
|
|
155
|
+
|
|
156
|
+
Outputs: `total`, `high` and `report-dir`.
|
|
157
|
+
|
|
158
|
+
## Migration packs: where the data comes from
|
|
159
|
+
|
|
160
|
+
What the scanner knows lives in **migration packs**, versioned JSON files with one pack per provider. Each pack
|
|
161
|
+
holds the model IDs and aliases, shutdown dates, replacements, call-site patterns, and parameter and endpoint
|
|
162
|
+
rules. Every fact links to the provider's documentation and the date we checked it. Rules we confirmed
|
|
163
|
+
against the live API are marked `live-tested`, and anything we assumed is marked `inferred`.
|
|
164
|
+
|
|
165
|
+
- **OpenAI**: full pack
|
|
166
|
+
- **Anthropic**: preview (stub). It covers Claude API retirement dates from Anthropic's deprecations page.
|
|
167
|
+
Amazon Bedrock and Google Cloud dates are not covered yet.
|
|
168
|
+
- Gemini, Azure OpenAI and Bedrock: planned
|
|
169
|
+
|
|
170
|
+
The tool checks for a newer pack once a day and falls back to the copy bundled with the package.
|
|
171
|
+
`lastorders packs list` shows which version and source you're using. The format is documented in
|
|
172
|
+
[docs/PACKS.md](docs/PACKS.md), with a JSON Schema in `migration_kit/pack_data/pack.schema.json`.
|
|
173
|
+
|
|
174
|
+
## Privacy and network use
|
|
175
|
+
|
|
176
|
+
- The scan runs **entirely on your machine**. Your code and prompts are never uploaded.
|
|
177
|
+
- **The tool has no telemetry, analytics or tracking.** (Our website counts visits anonymously with Cloudflare Web Analytics: no cookies, no ads, no tracking across sites.)
|
|
178
|
+
- The tool only ever contacts us to **download the latest migration pack** (a plain GET of a static JSON file,
|
|
179
|
+
with no identifiers) and, for paid features, to **check a licence key**. That check sends only the key and the
|
|
180
|
+
repo name (`github.com/owner/repo`), and the signed answer is cached for a week, with a 14-day offline grace period after that.
|
|
181
|
+
- `--offline` (or `LASTORDERS_OFFLINE=1`) turns off all network access and uses the bundled pack.
|
|
182
|
+
- The paid `eval` command calls the AI provider directly **with your own API key**. Nothing goes through us.
|
|
183
|
+
|
|
184
|
+
## Paid features
|
|
185
|
+
|
|
186
|
+
| | Free | Licence (per repo) | Done for you |
|
|
187
|
+
|---|---|---|---|
|
|
188
|
+
| `scan`, GitHub Action, patch preview (`fix` / `swap` write a `.diff`), `fix --verify` (simulated replay) | ✓ | ✓ | ✓ |
|
|
189
|
+
| `fix --apply` / `swap --apply`, `fix --verify --live`, `init-evals` + `eval` regression testing, client-ready `report` | | ✓ | ✓ |
|
|
190
|
+
| We do the migration, evals and handoff | | | ✓ |
|
|
191
|
+
|
|
192
|
+
One licence covers **one GitHub repository, monorepos included**. It's bound to the repo the first time
|
|
193
|
+
you use it. Buy one with `lastorders licence buy` (it prints a Stripe Checkout link for this repo), then pass the key
|
|
194
|
+
with `LASTORDERS_LICENCE` (a repository secret in Actions) or `--licence`. `lastorders licence status` shows
|
|
195
|
+
whether it's verified. Paid commands work `--offline` from the cached check. Repos without an `origin` remote
|
|
196
|
+
can set `LASTORDERS_REPO=github.com/owner/repo`. The licence server lives in `server/`; see
|
|
197
|
+
[docs/LICENCE-SERVER.md](docs/LICENCE-SERVER.md).
|
|
198
|
+
|
|
199
|
+
`init-evals` reads the call sites the scan found, including prompts, JSON schemas and tool definitions where
|
|
200
|
+
they can be read from the code, and writes a starter `evals/regression.yaml`. You then edit it into 10 to 20
|
|
201
|
+
real examples. `eval` runs them against the old and new model with your own key.
|
|
202
|
+
|
|
203
|
+
## Limitations
|
|
204
|
+
|
|
205
|
+
- Detection uses pattern matching. Model names built at runtime or stored in a database won't be found;
|
|
206
|
+
env-var lookups are listed so you can check them.
|
|
207
|
+
- A pack is only as current as its `updated` date. Always check the provider's documentation before acting.
|
|
208
|
+
- The scan suggests changes; it doesn't decide them for you. Review every patch.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
Made by [Burnt & Toast Studios](https://burntandtoast.com). Not affiliated with OpenAI or Anthropic.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
migration_kit/__init__.py,sha256=U9qijUvUFRPgvdGkwjck75q-QM0oETBGjERmkjihLb4,116
|
|
2
|
+
migration_kit/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
migration_kit/brand.py,sha256=glaklNIcsom1wu4P4LDsJMd_FJaVAFljKsZBMZ0pKjo,1400
|
|
4
|
+
migration_kit/cli.py,sha256=mp3ygiiz20TsTl3ILiFIzcOdER5a5BV4pKfcdrd8PN0,17437
|
|
5
|
+
migration_kit/evaluator.py,sha256=1X-fAsqhlq0la12EQM8PzALbs0SZBxohBdNrlxdHFd8,23767
|
|
6
|
+
migration_kit/initevals.py,sha256=inm4tkQRzCfF6JIL8buU-dXz0vi3MyQQSyKQ-ZBglEE,12757
|
|
7
|
+
migration_kit/licence.py,sha256=YvdrUOrP9GJM1KOvsEDa-6Xo7uH6eKw7TM0wPS5glq4,14864
|
|
8
|
+
migration_kit/packs.py,sha256=cd9QhUyTXlxdogWv-yoPgWVLKjXmR6jJjbaDT6_HE0U,11641
|
|
9
|
+
migration_kit/report.py,sha256=8ZVUnnW3ZQnVTWO33x5lXW58Uv_YLawPLgqq9izSgi0,21751
|
|
10
|
+
migration_kit/scanner.py,sha256=PnPwSgkb9YnsI7Va6jbRb2qt-iu1AmqP5vfDVwdEcRY,16846
|
|
11
|
+
migration_kit/site.py,sha256=ujeU2xvlwvMcCBIDEgJMc7-PqgPnuYAiNJHS8qeq8AA,3025
|
|
12
|
+
migration_kit/swapper.py,sha256=j5ad_fdztKfWOWGaSeJZyLViDjDze-n0rKZU3AZEfPs,4616
|
|
13
|
+
migration_kit/fixer/__init__.py,sha256=fYzn1CHJJ5d7qnvNlL5PxNHpcdlwu8ey-cuWxIQ8e6o,710
|
|
14
|
+
migration_kit/fixer/callsite.py,sha256=xe2UIB5mt10jSyMTKaDWtFbUwMvXUY3Mvl-TAM1NPzo,2040
|
|
15
|
+
migration_kit/fixer/engine.py,sha256=T0aP6W2rjL6h1Pt_6ZV1okh0K1lTSszyXxQXF6B2Bw8,23958
|
|
16
|
+
migration_kit/fixer/jsfix.py,sha256=iu1pvLMJlSH7DQgmCQzIb5lCJy45M1bPZYaoEgio7Qo,14444
|
|
17
|
+
migration_kit/fixer/pyfix.py,sha256=dq2fB5THHVyEK0SNdc72aGgN-t6ANeTVpoBiN1E8UcI,11090
|
|
18
|
+
migration_kit/fixer/verify.py,sha256=_aYYoZiLg0febNduxVghpvYqrH28dRGeOGVVf0UfWGA,14796
|
|
19
|
+
migration_kit/pack_data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
migration_kit/pack_data/anthropic.json,sha256=0c_BV0EnIhcUx-8PYKZAwZm1PTZZimj7ftUBr3xAZGs,8491
|
|
21
|
+
migration_kit/pack_data/openai.json,sha256=oLhW80Qg3h-bwry0ZJKteiiB_acUMK8XYoypS76NfMY,43013
|
|
22
|
+
migration_kit/pack_data/pack.schema.json,sha256=AYPeyGGAHE-yYTZROdOfaJ3W8VnhgxobS7ZxxEfh5FE,15465
|
|
23
|
+
lastorders-0.4.0.dist-info/METADATA,sha256=onlkFizEQfN1G_ZO43zjAdlTgZPxV5nsxohs_V-_29w,10747
|
|
24
|
+
lastorders-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
25
|
+
lastorders-0.4.0.dist-info/entry_points.txt,sha256=eayw4lQfz_JEGDcxa1Fn3PckPf2LtXOPTRncNCc_Zpo,54
|
|
26
|
+
lastorders-0.4.0.dist-info/top_level.txt,sha256=53GEur-LGzO74ESgMu4tb9PHtiQl3r1eMopbHxMK5Hk,14
|
|
27
|
+
lastorders-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
migration_kit
|
migration_kit/brand.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Product naming in ONE place: everything user-visible reads from here.
|
|
2
|
+
|
|
3
|
+
The product is "Last Orders" (CLI, package and PyPI name `lastorders`, env prefix LASTORDERS_),
|
|
4
|
+
chosen 15 Oct 2026. To rename again, run `python scripts/rename_product.py "New Name" newcli`, which
|
|
5
|
+
updates this file plus every static place Python can't read at build time (pyproject.toml,
|
|
6
|
+
action.yml, workflows, docs, tests, examples, site/ and server/)."""
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
PRODUCT_NAME = "Last Orders"
|
|
10
|
+
CLI_NAME = "lastorders" # console command, package and PyPI distribution name
|
|
11
|
+
ENV_PREFIX = "LASTORDERS" # env vars: LASTORDERS_OFFLINE, LASTORDERS_LICENCE, ...
|
|
12
|
+
COMPANY = "Burnt & Toast Studios"
|
|
13
|
+
SERVICE_URL = "https://packs.burntandtoast.com" # CLI-facing: licence API, pack files (server/)
|
|
14
|
+
SITE_URL = "https://lastorders.burntandtoast.com" # customer-facing: landing page, buy, terms
|
|
15
|
+
PACKS_URL = f"{SERVICE_URL}/v1"
|
|
16
|
+
LICENCE_URL = f"{SERVICE_URL}/api/licence/verify" # override: $LASTORDERS_LICENCE_URL
|
|
17
|
+
BUY_URL = f"{SITE_URL}/buy" # Stripe Checkout (append ?repo=github.com/owner/name to prefill)
|
|
18
|
+
TERMS_URL = f"{SITE_URL}/terms" # licence terms and refund policy
|
|
19
|
+
CACHE_DIR_NAME = CLI_NAME
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def env_name(key: str) -> str:
|
|
23
|
+
return f"{ENV_PREFIX}_{key}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def env(key: str, default: str = "") -> str:
|
|
27
|
+
return os.environ.get(env_name(key), default)
|
migration_kit/cli.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""CLI: scan | swap | fix | init-evals | eval | report | packs | mapping | licence | demo"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__, brand, licence, scanner
|
|
10
|
+
from .packs import load_packs
|
|
11
|
+
|
|
12
|
+
HERE = Path(__file__).resolve().parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _packs(a):
|
|
16
|
+
provs = [p.strip() for x in (a.pack or []) for p in x.split(",") if p.strip()] or None
|
|
17
|
+
return load_packs(provs, offline=a.offline or None, url=a.packs_url, extra_files=a.pack_file)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _off(a):
|
|
21
|
+
"""--offline if given, else None so <PREFIX>_OFFLINE decides."""
|
|
22
|
+
return True if getattr(a, "offline", False) else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_licence(a) -> int:
|
|
26
|
+
if a.action == "buy":
|
|
27
|
+
print(licence.buy_url(licence.repo_identity(a.repo)))
|
|
28
|
+
print(f"Terms and refunds: {brand.TERMS_URL}", file=sys.stderr)
|
|
29
|
+
return 0
|
|
30
|
+
lic = licence.resolve(a.licence, a.repo)
|
|
31
|
+
if a.action == "forget":
|
|
32
|
+
if lic.key and lic.repo_id:
|
|
33
|
+
licence.clear_cache(lic.key, lic.repo_id)
|
|
34
|
+
print("Cleared the cached licence verification for this repo." if lic.key else "No licence key set.")
|
|
35
|
+
return 0
|
|
36
|
+
lic = licence.require("licence check", a.licence, a.repo, offline=_off(a))
|
|
37
|
+
print(licence.describe(lic))
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_scan(a) -> int:
|
|
42
|
+
packs = _packs(a)
|
|
43
|
+
res = scanner.scan(a.repo, include_docs=not a.no_docs, packs=packs)
|
|
44
|
+
j, m = scanner.write_reports(res, a.out)
|
|
45
|
+
s = scanner.summarise(res)
|
|
46
|
+
if a.summary_file:
|
|
47
|
+
Path(a.summary_file).parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
with open(a.summary_file, "a", encoding="utf-8") as fh:
|
|
49
|
+
fh.write(scanner.to_summary_markdown(res) + "\n")
|
|
50
|
+
if a.annotations:
|
|
51
|
+
for line in scanner.github_annotations(res):
|
|
52
|
+
print(line)
|
|
53
|
+
if a.format == "json":
|
|
54
|
+
print(json.dumps(res.to_dict()["summary"], indent=2))
|
|
55
|
+
else:
|
|
56
|
+
packs_s = ", ".join(f"{p['provider']} v{p['version']} ({p['source']}"
|
|
57
|
+
+ (f"; {p['detail']}" if p["detail"] else "") + ")" for p in res.packs)
|
|
58
|
+
print(f"Scanned {res.files_scanned} files: {s['total_findings']} model references {s['by_risk']}. "
|
|
59
|
+
f"Fine-tunes: {len(s['fine_tuned_models'])}.\n packs: {packs_s}\n {m}\n {j}")
|
|
60
|
+
return 2 if a.fail_on_high and s["by_risk"].get("high") else 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_swap(a) -> int:
|
|
64
|
+
from . import swapper
|
|
65
|
+
if a.apply or licence.GATE_SWAP_PATCH:
|
|
66
|
+
print(licence.describe(licence.require("swap-apply", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
|
|
67
|
+
packs = _packs(a)
|
|
68
|
+
mapping = swapper.load_mapping(a.mapping) if a.mapping else swapper.mapping_from_packs(packs)
|
|
69
|
+
if a.include_docs:
|
|
70
|
+
mapping.include_docs = True
|
|
71
|
+
res = swapper.plan(a.repo, mapping, packs)
|
|
72
|
+
p, s = swapper.write(res, a.out)
|
|
73
|
+
print(f"{len(res.changes)} replacements in {len(res.new_contents)} files; "
|
|
74
|
+
f"{len(res.skipped_fine_tunes)} fine-tuned references left alone. Mapping: {mapping.source or a.mapping}")
|
|
75
|
+
print(f" patch: {p}\n summary: {s}")
|
|
76
|
+
if a.apply:
|
|
77
|
+
n = swapper.apply_in_place(a.repo, res)
|
|
78
|
+
print(f"Applied in place to {n} files (--apply).")
|
|
79
|
+
else:
|
|
80
|
+
print(f"Nothing changed on disk. Review, then: git -C {a.repo} apply {Path(p).resolve()}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dirty(repo: str, files: list[str]) -> list[str]:
|
|
85
|
+
"""Touched files with uncommitted changes (only when the repo is a git checkout)."""
|
|
86
|
+
import shutil
|
|
87
|
+
import subprocess
|
|
88
|
+
git = shutil.which("git")
|
|
89
|
+
if not git or not files:
|
|
90
|
+
return []
|
|
91
|
+
r = subprocess.run([git, "-C", repo, "status", "--porcelain", "--", *files], capture_output=True, text=True)
|
|
92
|
+
return [ln[3:] for ln in r.stdout.splitlines()] if r.returncode == 0 else []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_fix(a) -> int:
|
|
96
|
+
from . import fixer, swapper
|
|
97
|
+
from .fixer import verify as fverify
|
|
98
|
+
if a.apply or licence.GATE_FIX_PATCH:
|
|
99
|
+
print(licence.describe(licence.require("fix-apply", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
|
|
100
|
+
if a.verify and a.live and not a._demo:
|
|
101
|
+
print(licence.describe(licence.require("eval", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
|
|
102
|
+
packs = _packs(a)
|
|
103
|
+
mapping = swapper.load_mapping(a.mapping) if a.mapping else None
|
|
104
|
+
res = fixer.plan_fixes(a.repo, packs, mapping=mapping, model_swap=not a.no_model_swap,
|
|
105
|
+
assume_model=a.assume_model, include_inferred=a.include_inferred)
|
|
106
|
+
paths = fixer.write(res, a.out)
|
|
107
|
+
c = res.counts()
|
|
108
|
+
print(f"{len(res.new_contents)} files changed ({res.swap_changes} model ID replacements). "
|
|
109
|
+
+ ", ".join(f"{k}: {v}" for k, v in sorted(c.items())))
|
|
110
|
+
print(f" patch: {paths['diff']}\n plan: {paths['md']}")
|
|
111
|
+
ok = True
|
|
112
|
+
if a.verify:
|
|
113
|
+
rep = fverify.verify(a.repo, res, packs, Path(a.out) / "verify", live=a.live, suite_path=a.evals,
|
|
114
|
+
assume_model=a.assume_model, include_inferred=a.include_inferred)
|
|
115
|
+
ok = rep["ok"]
|
|
116
|
+
print(f" verify: {'PASS' if ok else 'FAIL'} ({'live' if a.live else 'mock'} eval) -> {Path(a.out) / 'verify' / 'verify.md'}")
|
|
117
|
+
if a.apply:
|
|
118
|
+
if not ok:
|
|
119
|
+
print("Not applied: verification failed (see verify.md).", file=sys.stderr)
|
|
120
|
+
return 6
|
|
121
|
+
dirty = _dirty(a.repo, sorted(res.new_contents))
|
|
122
|
+
if dirty and not a.force:
|
|
123
|
+
print("Not applied: these files have uncommitted changes (commit or stash them, or pass --force): "
|
|
124
|
+
+ ", ".join(dirty), file=sys.stderr)
|
|
125
|
+
return 7
|
|
126
|
+
n = fixer.engine.apply_in_place(a.repo, res)
|
|
127
|
+
print(f"Applied in place to {n} files (--apply).")
|
|
128
|
+
else:
|
|
129
|
+
print(f"Nothing changed on disk. Review, then: git -C {a.repo} apply {Path(paths['diff']).resolve()}")
|
|
130
|
+
return 0 if ok else 6
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_init_evals(a) -> int:
|
|
134
|
+
from . import initevals
|
|
135
|
+
if not a._demo:
|
|
136
|
+
print(licence.describe(licence.require("init-evals", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
|
|
137
|
+
packs = _packs(a)
|
|
138
|
+
suite, skipped = initevals.build_suite(a.repo, packs=packs)
|
|
139
|
+
out = initevals.write_suite(suite, skipped, a.out)
|
|
140
|
+
todos = sum(1 for c in suite["cases"] if "todo" in c or "TODO" in str(c.get("prompt", "")))
|
|
141
|
+
print(f"Wrote {out}: {len(suite['cases'])} case(s) from call sites ({todos} need your input). "
|
|
142
|
+
f"Old {suite['models']['old']} -> new {suite['models']['new']}.")
|
|
143
|
+
for s in skipped:
|
|
144
|
+
print(f" not covered: {s}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_eval(a) -> int:
|
|
149
|
+
from . import evaluator
|
|
150
|
+
if not a._demo:
|
|
151
|
+
print(licence.describe(licence.require("eval", a.licence, ".", offline=_off(a))), file=sys.stderr)
|
|
152
|
+
suite = evaluator.load_suite(a.suite)
|
|
153
|
+
if a.api:
|
|
154
|
+
suite["api"] = a.api
|
|
155
|
+
todo = [c["id"] for c in suite["cases"] if "TODO" in str(c.get("prompt", "")) and not c.get("skip")]
|
|
156
|
+
provider, warn = evaluator.make_provider(suite, a.dry_run)
|
|
157
|
+
if todo and provider.name != "mock":
|
|
158
|
+
print(f"{len(todo)} case(s) still have TODO prompts ({', '.join(todo[:5])}). Replace them with real inputs "
|
|
159
|
+
"(or set `skip: true`) before a live run; nothing was sent.", file=sys.stderr)
|
|
160
|
+
return 5
|
|
161
|
+
if warn:
|
|
162
|
+
print(warn, file=sys.stderr)
|
|
163
|
+
res = evaluator.run_suite(suite, provider, a.old, a.new)
|
|
164
|
+
j, m = evaluator.write(res, a.out)
|
|
165
|
+
print(f"{res['mode']} run of {len(res['cases'])} cases: {res['summary']}\n {m}\n {j}")
|
|
166
|
+
bad = res["summary"].get("fail", 0) + res["summary"].get("error", 0)
|
|
167
|
+
return 3 if a.fail_on_regression and bad else 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_report(a) -> int:
|
|
171
|
+
from . import report
|
|
172
|
+
if not a._demo:
|
|
173
|
+
root = json.loads(Path(a.scan).read_text()).get("root", ".")
|
|
174
|
+
print(licence.describe(licence.require("report", a.licence, root if Path(root).exists() else ".", offline=_off(a))), file=sys.stderr)
|
|
175
|
+
md, ht = report.generate(a.client, a.scan, a.out, a.swap_summary, a.diff, a.eval, label=a.label, packs=_packs(a),
|
|
176
|
+
fix_plan_path=a.fix_plan, fix_verify_path=a.fix_verify)
|
|
177
|
+
print(f" {ht}\n {md}")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_packs(a) -> int:
|
|
182
|
+
from . import packs as pk
|
|
183
|
+
if a.action == "list":
|
|
184
|
+
for i in _packs(a).info():
|
|
185
|
+
print(f"{i['provider']:<10} v{i['version']:<12} {i['status']:<7} {i['source']}"
|
|
186
|
+
+ (f" ({i['detail']})" if i["detail"] else "") + f" {i['name']}")
|
|
187
|
+
elif a.action == "validate":
|
|
188
|
+
for f in a.files:
|
|
189
|
+
if Path(f).name == "pack.schema.json":
|
|
190
|
+
continue
|
|
191
|
+
pk.validate(json.loads(Path(f).read_text()))
|
|
192
|
+
print(f"OK {f}")
|
|
193
|
+
elif a.action == "build-site":
|
|
194
|
+
from .site import build_site
|
|
195
|
+
out = build_site(Path(a.out))
|
|
196
|
+
print(f"Static site written to {out}")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_mapping(a) -> int:
|
|
201
|
+
from . import swapper
|
|
202
|
+
import yaml
|
|
203
|
+
m = swapper.mapping_from_packs(_packs(a), a.migration or None)
|
|
204
|
+
text = (f"# {m.source}\n" + yaml.safe_dump({"source": m.source, "mappings": m.replacements,
|
|
205
|
+
"fine_tuned": {}, "include_docs": False}, sort_keys=False))
|
|
206
|
+
if a.out:
|
|
207
|
+
Path(a.out).write_text(text)
|
|
208
|
+
print(f"Wrote {a.out}")
|
|
209
|
+
else:
|
|
210
|
+
print(text)
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cmd_demo(a) -> int:
|
|
215
|
+
"""Run the whole pipeline on examples/ offline (source checkout only; no licence needed)."""
|
|
216
|
+
ex = HERE / "examples"
|
|
217
|
+
if not ex.exists():
|
|
218
|
+
print("demo needs a source checkout (examples/ not found)", file=sys.stderr)
|
|
219
|
+
return 1
|
|
220
|
+
out = Path(a.out)
|
|
221
|
+
repo = ex / "sample-app"
|
|
222
|
+
steps = [["scan", str(repo), "--out", str(out), "--offline"],
|
|
223
|
+
["swap", str(repo), "--mapping", str(HERE / "mappings" / "gpt4-series-to-gpt-6-luna.yaml"), "--out", str(out), "--offline"],
|
|
224
|
+
["fix", str(repo), "--out", str(out), "--verify", "--offline"],
|
|
225
|
+
["eval", str(ex / "evals" / "sample-suite.yaml"), "--dry-run", "--out", str(out)],
|
|
226
|
+
["report", "--client", "Acme Widgets Ltd (sample)", "--scan", str(out / "scan.json"),
|
|
227
|
+
"--swap-summary", str(out / "swap-summary.json"), "--diff", str(out / "fix.diff"),
|
|
228
|
+
"--fix-plan", str(out / "fix-plan.json"), "--fix-verify", str(out / "verify" / "verify.json"),
|
|
229
|
+
"--eval", str(out / "eval.json"), "--out", str(out), "--offline",
|
|
230
|
+
"--label", "Demo: simulated eval outputs on a fictional sample repo"]]
|
|
231
|
+
for args in steps:
|
|
232
|
+
print(f"$ {brand.CLI_NAME} {' '.join(args)}")
|
|
233
|
+
rc = main(args, _demo=True)
|
|
234
|
+
if rc:
|
|
235
|
+
return rc
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _pack_opts(p):
|
|
240
|
+
g = p.add_argument_group("migration packs")
|
|
241
|
+
g.add_argument("--pack", action="append", help="provider pack(s) to use, e.g. openai,anthropic (default: all)")
|
|
242
|
+
g.add_argument("--pack-file", action="append", help="load a local pack JSON (overrides same provider)")
|
|
243
|
+
g.add_argument("--offline", action="store_true",
|
|
244
|
+
help=f"no network at all: use the bundled pack (or ${brand.env_name('OFFLINE')}=1)")
|
|
245
|
+
g.add_argument("--packs-url", help=f"base URL for hosted packs (default: ${brand.env_name('PACKS_URL')} or built-in)")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _lic_opt(p):
|
|
249
|
+
p.add_argument("--licence", "--license", dest="licence", help=f"licence key (or set ${licence.ENV_VAR})")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
253
|
+
p = argparse.ArgumentParser(prog=brand.CLI_NAME, description=f"{brand.PRODUCT_NAME}: find and fix retiring AI model IDs in your code.")
|
|
254
|
+
p.add_argument("--version", action="version", version=f"{brand.CLI_NAME} {__version__}")
|
|
255
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
256
|
+
|
|
257
|
+
s = sub.add_parser("scan", help="free: find retiring model IDs in a repo")
|
|
258
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
259
|
+
s.add_argument("--out", default=f"{brand.CLI_NAME}-out")
|
|
260
|
+
s.add_argument("--no-docs", action="store_true", help="skip .md/.txt files")
|
|
261
|
+
s.add_argument("--fail-on-high", action="store_true", help="exit 2 if any high-risk finding (for CI)")
|
|
262
|
+
s.add_argument("--summary-file", help="append a compact markdown summary (e.g. $GITHUB_STEP_SUMMARY)")
|
|
263
|
+
s.add_argument("--annotations", action="store_true", help="print GitHub Actions annotations")
|
|
264
|
+
s.add_argument("--format", choices=["text", "json"], default="text", help="stdout format")
|
|
265
|
+
_pack_opts(s)
|
|
266
|
+
s.set_defaults(fn=cmd_scan)
|
|
267
|
+
|
|
268
|
+
w = sub.add_parser("swap", help="write a reviewable patch (--apply needs a licence)")
|
|
269
|
+
w.add_argument("repo")
|
|
270
|
+
w.add_argument("--mapping", help="mapping YAML (default: generated from the packs' headline migrations)")
|
|
271
|
+
w.add_argument("--out", default=f"{brand.CLI_NAME}-out")
|
|
272
|
+
w.add_argument("--include-docs", action="store_true")
|
|
273
|
+
w.add_argument("--apply", action="store_true", help="ALSO edit files in place [licence]")
|
|
274
|
+
_lic_opt(w)
|
|
275
|
+
_pack_opts(w)
|
|
276
|
+
w.set_defaults(fn=cmd_swap)
|
|
277
|
+
|
|
278
|
+
fx = sub.add_parser("fix", help="automatic fixes from the pack rules, as a reviewable patch (--apply needs a licence)")
|
|
279
|
+
fx.add_argument("repo", nargs="?", default=".")
|
|
280
|
+
fx.add_argument("--out", default=f"{brand.CLI_NAME}-out")
|
|
281
|
+
fx.add_argument("--mapping", help="mapping YAML for the model swap (default: generated from the packs)")
|
|
282
|
+
fx.add_argument("--no-model-swap", action="store_true",
|
|
283
|
+
help="only fix arguments; fixes that need the new model are then listed, not applied")
|
|
284
|
+
fx.add_argument("--assume-model", help="model to assume for calls whose model isn't visible in the code")
|
|
285
|
+
fx.add_argument("--include-inferred", action="store_true", help="also apply rules marked 'inferred' in the pack")
|
|
286
|
+
fx.add_argument("--verify", action="store_true",
|
|
287
|
+
help="apply to a scratch copy, check syntax + idempotence, and replay each fixed call old vs new")
|
|
288
|
+
fx.add_argument("--live", action="store_true", help="with --verify: real API calls with YOUR key [licence]")
|
|
289
|
+
fx.add_argument("--evals", help="with --verify: also run your eval suite, with the fixed arguments on matching cases")
|
|
290
|
+
fx.add_argument("--apply", action="store_true", help="ALSO edit files in place [licence]")
|
|
291
|
+
fx.add_argument("--force", action="store_true", help="with --apply: allow files with uncommitted changes")
|
|
292
|
+
_lic_opt(fx)
|
|
293
|
+
_pack_opts(fx)
|
|
294
|
+
fx.set_defaults(fn=cmd_fix)
|
|
295
|
+
|
|
296
|
+
ie = sub.add_parser("init-evals", help="[licence] generate a starter eval suite from the scan's call sites")
|
|
297
|
+
ie.add_argument("repo", nargs="?", default=".")
|
|
298
|
+
ie.add_argument("--out", default="evals/regression.yaml")
|
|
299
|
+
_lic_opt(ie)
|
|
300
|
+
_pack_opts(ie)
|
|
301
|
+
ie.set_defaults(fn=cmd_init_evals)
|
|
302
|
+
|
|
303
|
+
e = sub.add_parser("eval", help="[licence] regression-test old vs new model (uses YOUR provider key)")
|
|
304
|
+
e.add_argument("suite")
|
|
305
|
+
e.add_argument("--old")
|
|
306
|
+
e.add_argument("--new")
|
|
307
|
+
e.add_argument("--api", choices=["chat", "responses"])
|
|
308
|
+
e.add_argument("--dry-run", "--mock", dest="dry_run", action="store_true", help="no API calls; simulated outputs")
|
|
309
|
+
e.add_argument("--out", default=f"{brand.CLI_NAME}-out")
|
|
310
|
+
e.add_argument("--fail-on-regression", action="store_true", help="exit 3 on any fail/error")
|
|
311
|
+
_lic_opt(e)
|
|
312
|
+
e.set_defaults(fn=cmd_eval)
|
|
313
|
+
|
|
314
|
+
r = sub.add_parser("report", help="[licence] client report (HTML + Markdown)")
|
|
315
|
+
r.add_argument("--client", required=True)
|
|
316
|
+
r.add_argument("--scan", required=True)
|
|
317
|
+
r.add_argument("--swap-summary")
|
|
318
|
+
r.add_argument("--diff")
|
|
319
|
+
r.add_argument("--eval")
|
|
320
|
+
r.add_argument("--label", help="banner at the top of the report")
|
|
321
|
+
r.add_argument("--fix-plan", help="fix-plan.json from `fix` (adds the automatic-fixes section)")
|
|
322
|
+
r.add_argument("--fix-verify", help="verify/verify.json from `fix --verify`")
|
|
323
|
+
r.add_argument("--out", default=f"{brand.CLI_NAME}-out")
|
|
324
|
+
_lic_opt(r)
|
|
325
|
+
_pack_opts(r)
|
|
326
|
+
r.set_defaults(fn=cmd_report)
|
|
327
|
+
|
|
328
|
+
k = sub.add_parser("packs", help="list / validate migration packs, or build the static hosting site")
|
|
329
|
+
k.add_argument("action", choices=["list", "validate", "build-site"])
|
|
330
|
+
k.add_argument("files", nargs="*", help="pack files (validate)")
|
|
331
|
+
k.add_argument("--out", default="site", help="output folder (build-site)")
|
|
332
|
+
_pack_opts(k)
|
|
333
|
+
k.set_defaults(fn=cmd_packs)
|
|
334
|
+
|
|
335
|
+
mp = sub.add_parser("mapping", help="print the default swap mapping generated from the packs")
|
|
336
|
+
mp.add_argument("--migration", action="append", help="migration id(s) (default: headline migrations)")
|
|
337
|
+
mp.add_argument("--out")
|
|
338
|
+
_pack_opts(mp)
|
|
339
|
+
mp.set_defaults(fn=cmd_mapping)
|
|
340
|
+
|
|
341
|
+
li = sub.add_parser("licence", aliases=["license"], help="check this repo's licence, print the buy link, or forget the cached check")
|
|
342
|
+
li.add_argument("action", choices=["status", "buy", "forget"])
|
|
343
|
+
li.add_argument("repo", nargs="?", default=".")
|
|
344
|
+
li.add_argument("--offline", action="store_true", help="use only the cached verification")
|
|
345
|
+
_lic_opt(li)
|
|
346
|
+
li.set_defaults(fn=cmd_licence)
|
|
347
|
+
|
|
348
|
+
d = sub.add_parser("demo", help="run everything on examples/ offline (source checkout)")
|
|
349
|
+
d.add_argument("--out", default=str(HERE / "examples" / "demo-output"))
|
|
350
|
+
d.set_defaults(fn=cmd_demo)
|
|
351
|
+
return p
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main(argv: list[str] | None = None, _demo: bool = False) -> int:
|
|
355
|
+
a = build_parser().parse_args(argv)
|
|
356
|
+
a._demo = _demo
|
|
357
|
+
try:
|
|
358
|
+
return a.fn(a)
|
|
359
|
+
except licence.LicenceError as e:
|
|
360
|
+
print(f"{brand.CLI_NAME}: {e}", file=sys.stderr)
|
|
361
|
+
return 4
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
raise SystemExit(main())
|