controlm-chain-admin 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 (61) hide show
  1. controlm_chain_admin/__init__.py +3 -0
  2. controlm_chain_admin/__main__.py +10 -0
  3. controlm_chain_admin/cli.py +1239 -0
  4. controlm_chain_admin/core/__init__.py +5 -0
  5. controlm_chain_admin/core/attribute_groups.py +61 -0
  6. controlm_chain_admin/core/commit.py +243 -0
  7. controlm_chain_admin/core/compare.py +806 -0
  8. controlm_chain_admin/core/constants.py +371 -0
  9. controlm_chain_admin/core/errors.py +131 -0
  10. controlm_chain_admin/core/fixes.py +433 -0
  11. controlm_chain_admin/core/graph.py +157 -0
  12. controlm_chain_admin/core/guide.py +76 -0
  13. controlm_chain_admin/core/history.py +335 -0
  14. controlm_chain_admin/core/inspect.py +105 -0
  15. controlm_chain_admin/core/intake.py +1148 -0
  16. controlm_chain_admin/core/jsonmodel.py +253 -0
  17. controlm_chain_admin/core/letter.py +169 -0
  18. controlm_chain_admin/core/links.py +299 -0
  19. controlm_chain_admin/core/membership.py +36 -0
  20. controlm_chain_admin/core/model.py +67 -0
  21. controlm_chain_admin/core/ops.py +656 -0
  22. controlm_chain_admin/core/overrides.py +113 -0
  23. controlm_chain_admin/core/package.py +318 -0
  24. controlm_chain_admin/core/refactor.py +1301 -0
  25. controlm_chain_admin/core/relations.py +194 -0
  26. controlm_chain_admin/core/repo.py +339 -0
  27. controlm_chain_admin/core/selection.py +189 -0
  28. controlm_chain_admin/core/take.py +567 -0
  29. controlm_chain_admin/core/validate.py +454 -0
  30. controlm_chain_admin/core/workspace.py +592 -0
  31. controlm_chain_admin/core/xmlio.py +151 -0
  32. controlm_chain_admin/py.typed +0 -0
  33. controlm_chain_admin/web/__init__.py +1 -0
  34. controlm_chain_admin/web/app.py +136 -0
  35. controlm_chain_admin/web/deps.py +27 -0
  36. controlm_chain_admin/web/errors.py +82 -0
  37. controlm_chain_admin/web/routers/__init__.py +1 -0
  38. controlm_chain_admin/web/routers/changes.py +55 -0
  39. controlm_chain_admin/web/routers/environments.py +26 -0
  40. controlm_chain_admin/web/routers/folders.py +100 -0
  41. controlm_chain_admin/web/routers/history.py +136 -0
  42. controlm_chain_admin/web/routers/intake.py +31 -0
  43. controlm_chain_admin/web/routers/jobs.py +63 -0
  44. controlm_chain_admin/web/routers/package.py +77 -0
  45. controlm_chain_admin/web/routers/repo.py +27 -0
  46. controlm_chain_admin/web/routers/table.py +17 -0
  47. controlm_chain_admin/web/routers/validate.py +36 -0
  48. controlm_chain_admin/web/routers/workspace.py +127 -0
  49. controlm_chain_admin/web/schemas.py +525 -0
  50. controlm_chain_admin/web/serve.py +23 -0
  51. controlm_chain_admin/web/settings.py +30 -0
  52. controlm_chain_admin/web/static/assets/graph-CA0VXlpl.js +31 -0
  53. controlm_chain_admin/web/static/assets/index-BA0sPihz.css +1 -0
  54. controlm_chain_admin/web/static/assets/index-DYc-B93N.js +28 -0
  55. controlm_chain_admin/web/static/assets/react-DB7G9u3x.js +28 -0
  56. controlm_chain_admin/web/static/index.html +16 -0
  57. controlm_chain_admin-0.1.0.dist-info/METADATA +186 -0
  58. controlm_chain_admin-0.1.0.dist-info/RECORD +61 -0
  59. controlm_chain_admin-0.1.0.dist-info/WHEEL +4 -0
  60. controlm_chain_admin-0.1.0.dist-info/entry_points.txt +2 -0
  61. controlm_chain_admin-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1239 @@
1
+ """``ctmchain`` command-line entry point (thin adapter over :mod:`controlm_chain_admin.core`).
2
+
3
+ The CLI only parses arguments, calls one core operation, renders its result and maps typed
4
+ errors to exit codes: 0 success, 1 findings reported, 2 usage error, 3 error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import json
12
+ import os
13
+ import secrets
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from controlm_chain_admin import __version__
20
+ from controlm_chain_admin.core import commit as commit_mod
21
+ from controlm_chain_admin.core import compare as compare_mod
22
+ from controlm_chain_admin.core import constants as constants_mod
23
+ from controlm_chain_admin.core import fixes as fixes_mod
24
+ from controlm_chain_admin.core import graph as graph_mod
25
+ from controlm_chain_admin.core import guide as guide_mod
26
+ from controlm_chain_admin.core import inspect as inspect_mod
27
+ from controlm_chain_admin.core import intake as intake_mod
28
+ from controlm_chain_admin.core import ops
29
+ from controlm_chain_admin.core import package as package_mod
30
+ from controlm_chain_admin.core import refactor as refactor_mod
31
+ from controlm_chain_admin.core import take as take_mod
32
+ from controlm_chain_admin.core import workspace as workspace_mod
33
+ from controlm_chain_admin.core.errors import ChainError, NotAChainRepo, TakeConflict
34
+ from controlm_chain_admin.core.history import WORKTREE
35
+ from controlm_chain_admin.core.links import NodeRef, folder_names
36
+ from controlm_chain_admin.core.repo import init_repo, load_repo, read_json, write_json
37
+ from controlm_chain_admin.core.validate import render_markdown, render_text, validate
38
+ from controlm_chain_admin.core.xmlio import parse_xml
39
+
40
+ EXIT_OK = 0
41
+ EXIT_FINDINGS = 1
42
+ EXIT_USAGE = 2
43
+ EXIT_ERROR = 3
44
+
45
+ DESCRIPTION = (
46
+ "Manage BMC Control-M job chains as a git-friendly JSON repository: import and export the "
47
+ "Control-M XML, keep links between jobs explicit, validate chains and refactor safely."
48
+ )
49
+
50
+
51
+ def _plural(count: int, noun: str) -> str:
52
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ """Build the parser with every subcommand."""
57
+ parser = argparse.ArgumentParser(prog="ctmchain", description=DESCRIPTION)
58
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
59
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
60
+
61
+ init = commands.add_parser("init", help="turn an existing directory into a chain repository")
62
+ init.add_argument(
63
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
64
+ )
65
+ init.add_argument(
66
+ "--application", required=True, help="Control-M APPLICATION managed by this repo"
67
+ )
68
+ init.add_argument(
69
+ "--envs",
70
+ required=True,
71
+ help="comma-separated environments with optional letter, e.g. test=T,uat=U,bench=B,prod=P",
72
+ )
73
+ init.add_argument(
74
+ "--prefixes",
75
+ default="",
76
+ help="comma-separated bare prefixes of the letter rule, e.g. P77,RQ_,RC_ (default: none)",
77
+ )
78
+ init.add_argument(
79
+ "--leading-only",
80
+ default="",
81
+ help="prefixes whose names carry the letter on the first token only, e.g. RQ_,RC_ "
82
+ "(resources such as URQ_P77_X)",
83
+ )
84
+ init.set_defaults(func=cmd_init)
85
+
86
+ imp = commands.add_parser("import", help="import a Control-M XML export into the repository")
87
+ imp.add_argument("xml", type=Path, help="Control-M export (DEFTABLE XML)")
88
+ imp.add_argument(
89
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
90
+ )
91
+ imp.add_argument(
92
+ "--env", help="environment the export comes from (needed with the letter rule)"
93
+ )
94
+ imp.add_argument(
95
+ "--mappings",
96
+ type=Path,
97
+ help="mapping draft (from distinct --write) to apply before importing",
98
+ )
99
+ imp.add_argument(
100
+ "--dedupe-jobnames",
101
+ action="store_true",
102
+ help="give repeated JOBNAMEs a numeric suffix (X, X2, X3); kept as the repo policy",
103
+ )
104
+ imp.add_argument("--format", choices=("text", "json"), default="text", help="report format")
105
+ imp.set_defaults(func=cmd_import)
106
+
107
+ exp = commands.add_parser("export", help="regenerate the Control-M XML from the repository")
108
+ exp.add_argument(
109
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
110
+ )
111
+ exp.add_argument("--env", help="target environment (needed with the letter rule)")
112
+ exp.add_argument(
113
+ "--jobnames",
114
+ choices=("deduped", "original"),
115
+ default="deduped",
116
+ help="JOBNAMEs as stored (default) or restored to the originals before deduplication",
117
+ )
118
+ exp.add_argument(
119
+ "--names",
120
+ choices=("original", "standard"),
121
+ default="original",
122
+ help="condition names: as imported (default) or derived from the endpoints",
123
+ )
124
+ exp.add_argument(
125
+ "--allow-unresolved",
126
+ action="store_true",
127
+ help="leave @{name} placeholders without a value in the XML instead of failing",
128
+ )
129
+ exp.add_argument("-o", "--output", type=Path, required=True, help="XML file to write")
130
+ exp.set_defaults(func=cmd_export)
131
+
132
+ val = commands.add_parser(
133
+ "validate", help="check links, names and duplicates; exit 1 on findings"
134
+ )
135
+ val.add_argument(
136
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
137
+ )
138
+ val.add_argument(
139
+ "--format", choices=("text", "md", "json"), default="text", help="report format"
140
+ )
141
+ val.add_argument("--strict", action="store_true", help="exit 1 on info findings too")
142
+ val.set_defaults(func=cmd_validate)
143
+
144
+ dis = commands.add_parser(
145
+ "distinct", help="distinct values of attributes, with counts and sample nodes"
146
+ )
147
+ dis.add_argument(
148
+ "specs", nargs="+", metavar="ATTR|TAG/@ATTR", help="e.g. NODEID RUN_AS DOMAIL/@DEST"
149
+ )
150
+ dis.add_argument("--xml", type=Path, help="read a Control-M export instead of the repository")
151
+ dis.add_argument(
152
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
153
+ )
154
+ dis.add_argument("--format", choices=("text", "json"), default="text")
155
+ dis.add_argument("--env", help="environment the values come from (prefills the draft)")
156
+ dis.add_argument("--write", type=Path, help="write a mapping draft to this JSON file")
157
+ dis.set_defaults(func=cmd_distinct)
158
+
159
+ show = commands.add_parser("show", help="print one node (FOLDER/JOB or FOLDER/) as JSON")
160
+ show.add_argument("node")
161
+ show.add_argument(
162
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
163
+ )
164
+ show.set_defaults(func=cmd_show)
165
+
166
+ gr = commands.add_parser("graph", help="chain graph of one folder")
167
+ gr.add_argument("folder")
168
+ gr.add_argument(
169
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
170
+ )
171
+ gr.add_argument("--format", choices=("text", "mermaid", "dot", "json"), default="text")
172
+ gr.set_defaults(func=cmd_graph)
173
+
174
+ ln = commands.add_parser(
175
+ "links", help="links of a node, or the link with a given condition name"
176
+ )
177
+ ln.add_argument("node", nargs="?", help="FOLDER/JOB or FOLDER/")
178
+ ln.add_argument("--name", help="condition name")
179
+ ln.add_argument(
180
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
181
+ )
182
+ ln.add_argument("--format", choices=("text", "json"), default="text")
183
+ ln.set_defaults(func=cmd_links)
184
+
185
+ mp = commands.add_parser(
186
+ "map", help="bind a value to a constant with one value per environment"
187
+ )
188
+ mp.add_argument("spec", metavar="ATTR|TAG/@ATTR", help="e.g. NODEID or DOMAIL/@DEST")
189
+ mp.add_argument("value", help="the value as seen in the export")
190
+ mp.add_argument("--as", dest="name", required=True, help="constant name, e.g. node.batch")
191
+ mp.add_argument("--pattern", help="template with {L}/{l} for the letter, e.g. {L}P77_BATCH")
192
+ mp.add_argument(
193
+ "--value",
194
+ dest="values",
195
+ action="append",
196
+ default=[],
197
+ metavar="ENV=VALUE",
198
+ help="explicit value for one environment (repeatable)",
199
+ )
200
+ mp.add_argument("--env", help="environment the given value belongs to")
201
+ mp.add_argument("--inline", action="store_true", help="the value is a token inside free text")
202
+ mp.add_argument(
203
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
204
+ )
205
+ mp.set_defaults(func=cmd_map)
206
+
207
+ envp = commands.add_parser("env", help="environment helpers")
208
+ env_sub = envp.add_subparsers(dest="env_command", metavar="<subcommand>")
209
+ envc = env_sub.add_parser(
210
+ "constants", help="constants table; --missing lists the gaps and exits 1"
211
+ )
212
+ envc.add_argument(
213
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
214
+ )
215
+ envc.add_argument("--env", help="only this environment")
216
+ envc.add_argument("--missing", action="store_true", help="only constants without a value")
217
+ envc.add_argument("--format", choices=("text", "json"), default="text")
218
+ envc.set_defaults(func=cmd_env_constants)
219
+ envl = env_sub.add_parser(
220
+ "learn", help="the values of the constants in an environment, from one of its exports"
221
+ )
222
+ envl.add_argument("xml", type=Path, help="an export of that environment")
223
+ envl.add_argument("--env", help="the environment (default: told by the letter of the names)")
224
+ envl.add_argument(
225
+ "--only",
226
+ action="append",
227
+ default=[],
228
+ metavar="PREFIX",
229
+ help="only the constants whose name starts with it, e.g. user. (repeatable)",
230
+ )
231
+ _refactor_options(envl)
232
+ envl.set_defaults(func=cmd_env_learn)
233
+ envp.set_defaults(func=cmd_env_help)
234
+
235
+ rec = commands.add_parser(
236
+ "receive",
237
+ help="take an export of one environment (some folders, e.g. a delivery) into the release",
238
+ )
239
+ rec.add_argument("xml", type=Path, help="the export")
240
+ rec.add_argument("--env", help="its environment (default: told by the letter of the names)")
241
+ scope = rec.add_mutually_exclusive_group()
242
+ scope.add_argument(
243
+ "--full",
244
+ action="store_const",
245
+ const=True,
246
+ dest="full",
247
+ help="every folder of the environment (default: when it has more than half of them)",
248
+ )
249
+ scope.add_argument(
250
+ "--partial",
251
+ action="store_const",
252
+ const=False,
253
+ dest="full",
254
+ help="some folders only, e.g. a delivery",
255
+ )
256
+ rec.add_argument(
257
+ "--choose",
258
+ action="append",
259
+ default=[],
260
+ metavar="ID=OPTION",
261
+ help="another option than the default for one decision (repeatable)",
262
+ )
263
+ _refactor_options(rec)
264
+ rec.set_defaults(func=cmd_receive)
265
+
266
+ cmp_ = commands.add_parser("compare", help="what changed in the chains between two git refs")
267
+ cmp_.add_argument("base", help="base ref, e.g. develop")
268
+ cmp_.add_argument("head", nargs="?", default="HEAD", help="head ref (default: HEAD)")
269
+ cmp_.add_argument(
270
+ "--worktree", action="store_true", help="compare base with the uncommitted working tree"
271
+ )
272
+ cmp_.add_argument(
273
+ "--no-merge-base",
274
+ action="store_true",
275
+ help="compare the two tips directly; by default the base side is the merge base, "
276
+ "so only the changes made on head are shown, as in a pull request",
277
+ )
278
+ _commit_option(cmp_)
279
+ cmp_.add_argument(
280
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
281
+ )
282
+ cmp_.add_argument("--format", choices=("text", "md", "json"), default="text")
283
+ cmp_.set_defaults(func=cmd_compare)
284
+
285
+ tk = commands.add_parser(
286
+ "take", help="take changes of another branch into the checked-out one, item by item"
287
+ )
288
+ tk.add_argument("head", help="the branch (tag, sha) to take from")
289
+ tk.add_argument("--base", default="HEAD", help="the checked-out branch (default: HEAD)")
290
+ tk.add_argument(
291
+ "--item",
292
+ action="append",
293
+ default=[],
294
+ metavar="ID",
295
+ help="an item of the comparison, e.g. job:F/J or attr:F/J@TIMEFROM (repeatable); "
296
+ "--list shows them",
297
+ )
298
+ tk.add_argument(
299
+ "--theirs",
300
+ action="append",
301
+ default=[],
302
+ metavar="ID",
303
+ help="a conflicting item to take anyway (repeatable)",
304
+ )
305
+ tk.add_argument("--list", action="store_true", help="list the items that can be taken")
306
+ tk.add_argument(
307
+ "--no-merge-base",
308
+ action="store_true",
309
+ help="take differences of the two tips, not only what head changed since they parted",
310
+ )
311
+ _commit_option(tk)
312
+ _refactor_options(tk)
313
+ tk.set_defaults(func=cmd_take)
314
+
315
+ pkg = commands.add_parser(
316
+ "package",
317
+ help="the Control-M import file with every folder changed between two git refs",
318
+ )
319
+ pkg.add_argument("base", help="base ref, e.g. develop")
320
+ pkg.add_argument("head", nargs="?", default="HEAD", help="head ref (default: HEAD)")
321
+ pkg.add_argument("--env", required=True, help="target environment, e.g. uat")
322
+ pkg.add_argument(
323
+ "--worktree", action="store_true", help="package the uncommitted working tree"
324
+ )
325
+ pkg.add_argument(
326
+ "--no-merge-base",
327
+ action="store_true",
328
+ help="compare the two tips directly instead of from the merge base",
329
+ )
330
+ _commit_option(pkg)
331
+ pkg.add_argument(
332
+ "--jobnames",
333
+ choices=("original", "deduped"),
334
+ default="original",
335
+ help="JOBNAMEs as in Control-M (default) or as stored after deduplication",
336
+ )
337
+ pkg.add_argument(
338
+ "--names",
339
+ choices=("original", "standard"),
340
+ default="original",
341
+ help="condition names: as imported (default) or derived from the endpoints",
342
+ )
343
+ pkg.add_argument(
344
+ "-o", "--output", type=Path, help="XML file to write (default: a name like deploy_uat_...)"
345
+ )
346
+ pkg.add_argument(
347
+ "--dry-run", action="store_true", help="list the folders and why, write nothing"
348
+ )
349
+ pkg.add_argument(
350
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
351
+ )
352
+ pkg.add_argument("--format", choices=("text", "json"), default="text")
353
+ pkg.set_defaults(func=cmd_package)
354
+
355
+ res = commands.add_parser(
356
+ "resolve",
357
+ help="fix validation findings: list the fixes per code, or preview/apply CODE=KIND",
358
+ )
359
+ res.add_argument(
360
+ "choices",
361
+ nargs="*",
362
+ metavar="CODE=KIND",
363
+ help="the fix kind to use for every finding of a code, e.g. never-cleaned=add-cleanup",
364
+ )
365
+ _refactor_options(res)
366
+ res.set_defaults(func=cmd_resolve)
367
+
368
+ com = commands.add_parser(
369
+ "commit", help="commit the chain files, with a message written from what changed"
370
+ )
371
+ com.add_argument("-m", "--message", help="commit message (default: the suggested one)")
372
+ com.add_argument("--branch", help="create this branch and commit there")
373
+ com.add_argument(
374
+ "--dry-run", action="store_true", help="show the files and the message, commit nothing"
375
+ )
376
+ com.add_argument(
377
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
378
+ )
379
+ com.set_defaults(func=cmd_commit)
380
+
381
+ _add_release(commands)
382
+ _add_refactorings(commands)
383
+
384
+ srv = commands.add_parser(
385
+ "serve", help="serve the local web UI and API (needs the [web] extra)"
386
+ )
387
+ srv.add_argument(
388
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
389
+ )
390
+ srv.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
391
+ srv.add_argument("--port", type=int, default=8765, help="port (default: 8765)")
392
+ srv.add_argument("--no-browser", action="store_true", help="do not open the browser")
393
+ srv.add_argument(
394
+ "--check", action="store_true", help="build the app and print the URL, do not listen"
395
+ )
396
+ srv.set_defaults(func=cmd_serve)
397
+ return parser
398
+
399
+
400
+ # ---------------------------------------------------------------- commands
401
+
402
+
403
+ def cmd_init(args: argparse.Namespace) -> int:
404
+ environments: list[str] = []
405
+ letters: dict[str, str] = {}
406
+ for item in (e.strip() for e in args.envs.split(",") if e.strip()):
407
+ name, _, letter = item.partition("=")
408
+ environments.append(name)
409
+ if letter:
410
+ letters[name] = letter
411
+ if not environments:
412
+ raise ChainError("--envs needs at least one environment name")
413
+ prefixes = [p.strip() for p in args.prefixes.split(",") if p.strip()]
414
+ leading = [p.strip() for p in args.leading_only.split(",") if p.strip()]
415
+ repo = init_repo(
416
+ args.repo, args.application, environments, letters=letters, prefixes=prefixes,
417
+ leading_only=leading,
418
+ ) # fmt: skip
419
+ print(
420
+ f"Initialized chain repository at {repo.root} for application {repo.config.application} "
421
+ f"with environments {', '.join(repo.config.environments)}"
422
+ )
423
+ if guide_mod.write_guide(repo):
424
+ print(f"Wrote {guide_mod.GUIDE_FILE}: the rules of this repository, for people and agents")
425
+ return EXIT_OK
426
+
427
+
428
+ def cmd_import(args: argparse.Namespace) -> int:
429
+ repo = load_repo(args.repo)
430
+ defined: tuple[str, ...] = ()
431
+ if args.mappings is not None:
432
+ repo, defined = constants_mod.apply_draft(repo, read_json(args.mappings))
433
+ report = ops.import_xml(
434
+ repo,
435
+ args.xml.read_bytes(),
436
+ env=args.env,
437
+ dedupe_jobnames=True if args.dedupe_jobnames else None,
438
+ )
439
+ if args.format == "json":
440
+ print(json.dumps(report.to_json(), indent=2, ensure_ascii=False))
441
+ return EXIT_OK
442
+ print(
443
+ f"Imported {_plural(len(report.folders), 'folder')}, {_plural(report.jobs, 'job')} "
444
+ f"(application {report.application}); {_plural(report.files_written, 'file')} written."
445
+ )
446
+ if report.skipped:
447
+ names = ", ".join(f"{s.folder} ({s.application})" for s in report.skipped)
448
+ print(f"Skipped {_plural(len(report.skipped), 'folder')} of other applications: {names}")
449
+ if report.jobnames_deduped:
450
+ print(f"Deduplicated JOBNAMEs: {len(report.jobnames_deduped)}")
451
+ for rename in report.jobnames_deduped:
452
+ print(f" {rename}")
453
+ if report.duplicate_job_names:
454
+ print(f"Repeated JOBNAME inside a folder: {', '.join(report.duplicate_job_names)}")
455
+ if report.literal_marks:
456
+ print(f"Literal marks (bare prefix, no letter): {len(report.literal_marks)}")
457
+ for mark in report.literal_marks:
458
+ print(f" {mark}")
459
+ if report.suspicious_values:
460
+ print(f"Values carrying another environment's letter: {len(report.suspicious_values)}")
461
+ for value in report.suspicious_values:
462
+ print(f" {value}")
463
+ if defined:
464
+ print(f"Constants defined from the mappings: {len(defined)} ({', '.join(defined)})")
465
+ if report.constants_replaced or report.unmapped:
466
+ print(f"Constants replaced: {report.constants_replaced}")
467
+ for spec, values in report.unmapped.items():
468
+ print(f"Unmapped {spec} ({len(values)}): {', '.join(values)}")
469
+ return EXIT_OK
470
+
471
+
472
+ def cmd_export(args: argparse.Namespace) -> int:
473
+ repo = load_repo(args.repo)
474
+ data, report = ops.export_document(
475
+ repo,
476
+ env=args.env,
477
+ allow_unresolved=args.allow_unresolved,
478
+ names=args.names,
479
+ jobnames=args.jobnames,
480
+ )
481
+ args.output.write_bytes(data)
482
+ for name in report.constants.unresolved:
483
+ print(
484
+ f"warning: constant {name} has no value in {args.env}; placeholder left in the XML",
485
+ file=sys.stderr,
486
+ )
487
+ print(f"Wrote {args.output} ({len(data)} bytes)")
488
+ if report.renamed:
489
+ print(f"Standard names applied: {report.renamed} link(s) renamed")
490
+ return EXIT_OK
491
+
492
+
493
+ def cmd_validate(args: argparse.Namespace) -> int:
494
+ report = validate(load_repo(args.repo))
495
+ if args.format == "json":
496
+ print(json.dumps(report.to_json(), indent=2, ensure_ascii=False))
497
+ elif args.format == "md":
498
+ print(render_markdown(report), end="")
499
+ else:
500
+ print(render_text(report), end="")
501
+ threshold = ("error", "warning", "info") if args.strict else ("error", "warning")
502
+ return EXIT_FINDINGS if any(report.count(s) for s in threshold) else EXIT_OK
503
+
504
+
505
+ def cmd_distinct(args: argparse.Namespace) -> int:
506
+ if args.xml is not None:
507
+ document = parse_xml(args.xml.read_bytes())
508
+ # inside a chain repository only the repository's application is relevant
509
+ with contextlib.suppress(NotAChainRepo):
510
+ document = ops.only_application(document, load_repo(args.repo).config.application)
511
+ else:
512
+ document = inspect_mod.load_document(load_repo(args.repo))
513
+ result = inspect_mod.distinct_values(document, args.specs)
514
+ if args.write is not None:
515
+ draft = constants_mod.build_draft(load_repo(args.repo), document, args.specs, args.env)
516
+ write_json(args.write, draft)
517
+ print(
518
+ f"Wrote mapping draft {args.write} ({_plural(len(args.specs), 'attribute')}); "
519
+ "fill 'as' and 'values' or 'pattern', then import --mappings"
520
+ )
521
+ return EXIT_OK
522
+ if args.format == "json":
523
+ payload = {spec: [v.to_json() for v in values] for spec, values in result.items()}
524
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
525
+ return EXIT_OK
526
+ for spec, values in result.items():
527
+ print(f"{spec} ({_plural(len(values), 'distinct value')})")
528
+ for value in values:
529
+ print(f" {value.count:6} {value.value} <- {', '.join(value.samples)}")
530
+ return EXIT_OK
531
+
532
+
533
+ def cmd_map(args: argparse.Namespace) -> int:
534
+ repo = load_repo(args.repo)
535
+ selector = constants_mod.parse_spec(args.spec, inline=args.inline)
536
+ values: dict[str, str | None] = {}
537
+ if args.pattern:
538
+ values.update(constants_mod.values_from_pattern(args.pattern, repo.env_letters()))
539
+ for item in args.values:
540
+ env, sep, value = item.partition("=")
541
+ if not sep or not env:
542
+ raise ChainError(f"--value expects ENV=VALUE, got {item!r}")
543
+ values[env] = value
544
+ if args.env:
545
+ values[args.env] = args.value
546
+ if not values:
547
+ raise ChainError("map needs --pattern, --value ENV=VALUE or --env to know the values")
548
+ repo = constants_mod.define_constant(repo, args.name, selector, values)
549
+ table = constants_mod.constants_table(repo)[args.name]
550
+ cells = ", ".join(
551
+ f"{env}={value if value is not None else '?'}" for env, value in table.items()
552
+ )
553
+ print(f"{args.name} ({selector.spec}): {cells}")
554
+ return EXIT_OK
555
+
556
+
557
+ def cmd_env_help(args: argparse.Namespace) -> int:
558
+ print(
559
+ "usage: ctmchain env constants [--repo DIR] [--env ENV] [--missing] [--format text|json]"
560
+ )
561
+ print(" ctmchain env learn XML [--env ENV] [--repo DIR] [--apply] [--stat]")
562
+ return EXIT_USAGE
563
+
564
+
565
+ _SHOWN_PER_KIND = 20
566
+ _DECISION_ORDER = (
567
+ "learn", "set-value", "group", "constant", "unmatched", "value", "children", "removed",
568
+ "added", "absent",
569
+ ) # fmt: skip
570
+
571
+
572
+ def _decision_line(decision: intake_mod.Decision) -> str:
573
+ options = "|".join(decision.options)
574
+ head = f" {decision.id} {decision.default} [{options}]"
575
+ uses = (
576
+ f" ({_plural(decision.count, 'use')})" if decision.kind in ("learn", "set-value") else ""
577
+ )
578
+ if decision.kind == "learn":
579
+ return f"{head} {decision.constant} = {decision.incoming}{uses}"
580
+ if decision.kind == "set-value":
581
+ return f"{head} {decision.constant}: {decision.expected} -> {decision.incoming}{uses}"
582
+ if decision.kind in ("value", "constant"):
583
+ return f"{head} {decision.current} -> {decision.incoming}"
584
+ if decision.kind == "unmatched":
585
+ uses = _plural(decision.count, "use")
586
+ return (
587
+ f"{head} {decision.field} = {decision.incoming} ({uses}); new: {decision.suggested}"
588
+ )
589
+ if decision.kind == "group":
590
+ uses = _plural(decision.count, "use")
591
+ values = ", ".join(f"{env} {value}" for env, value in (decision.values or {}).items())
592
+ if decision.inline:
593
+ what = f"{decision.field}: the name of the environment inside the text"
594
+ values = f"inline: {values}"
595
+ else:
596
+ what = f"{decision.field} {decision.current} -> {decision.incoming}"
597
+ return f"{head} {what} ({uses}); new: {decision.suggested} ({values})"
598
+ return head
599
+
600
+
601
+ def _print_intake(intake: intake_mod.Intake) -> None:
602
+ print(f"{intake.env} export: {'; '.join(intake.evidence)}")
603
+ statuses = {
604
+ s: sum(1 for f in intake.folders if f.status == s) for s in ("new", "changed", "same")
605
+ }
606
+ print(
607
+ f"Folders: {len(intake.folders)} ({statuses['new']} new, {statuses['changed']} changed, "
608
+ f"{statuses['same']} unchanged)"
609
+ + (", full export" if intake.full else ", some folders only" if intake.partial else "")
610
+ )
611
+ if intake.decisions:
612
+ print(
613
+ "Decisions (default [options]; another one with --choose ID=OPTION, "
614
+ "a new constant with ID=new:NAME):"
615
+ )
616
+ for kind in _DECISION_ORDER:
617
+ of_kind = [d for d in intake.decisions if d.kind == kind]
618
+ for decision in of_kind[:_SHOWN_PER_KIND]:
619
+ print(_decision_line(decision))
620
+ if len(of_kind) > _SHOWN_PER_KIND:
621
+ print(f" ... {len(of_kind) - _SHOWN_PER_KIND} more {kind} decisions (--format json)")
622
+ for line in intake.unresolved:
623
+ print(f"unresolved: {line}")
624
+ for warning in intake.warnings:
625
+ print(f"warning: {warning}")
626
+ print()
627
+
628
+
629
+ def cmd_receive(args: argparse.Namespace) -> int:
630
+ repo = load_repo(args.repo)
631
+ data = args.xml.read_bytes()
632
+ choices = _pairs(args.choose, "ID=OPTION", last=True) # ids may hold "=", options do not
633
+ intake = intake_mod.analyze(repo, data, args.env, args.full)
634
+ if args.format == "text":
635
+ _print_intake(intake)
636
+ if args.apply:
637
+ workspace_mod.ensure_editable(repo)
638
+ change = intake_mod.receive(repo, data, intake.env, choices, intake.full)
639
+ if args.format == "json":
640
+ result = refactor_mod.apply(repo, change) if args.apply and change.edits else None
641
+ body = {"intake": intake.to_json(), "change": change.to_json(), "applied": bool(result)}
642
+ print(json.dumps(body, indent=2, ensure_ascii=False))
643
+ return EXIT_OK
644
+ args.build = lambda _r, _a: change
645
+ return cmd_refactor(args)
646
+
647
+
648
+ def cmd_env_learn(args: argparse.Namespace) -> int:
649
+ repo = load_repo(args.repo)
650
+ intake = intake_mod.analyze(repo, args.xml.read_bytes(), args.env)
651
+ wanted = [
652
+ d
653
+ for d in intake.decisions
654
+ if d.kind in ("learn", "set-value")
655
+ and d.constant
656
+ and (not args.only or d.constant.startswith(tuple(args.only)))
657
+ ]
658
+ values = {d.constant: d.incoming for d in wanted if d.constant}
659
+ print(f"{intake.env} export: {'; '.join(intake.evidence)}")
660
+ for decision in wanted:
661
+ if decision.kind in ("learn", "set-value"):
662
+ before = decision.expected if decision.kind == "set-value" else None
663
+ print(
664
+ f" {decision.constant}: {before or '?'} -> {decision.incoming} "
665
+ f"({_plural(decision.count, 'use')})"
666
+ )
667
+ disagree = sum(1 for d in intake.decisions if d.kind == "constant")
668
+ if disagree:
669
+ print(f"{_plural(disagree, 'value')} of single jobs disagree: see ctmchain receive")
670
+ if not values:
671
+ print("Nothing to learn.")
672
+ return EXIT_OK
673
+ args.build = lambda r, _a: refactor_mod.run_operation(
674
+ r, "set-values", {"env": intake.env, "values": values}
675
+ )
676
+ return cmd_refactor(args)
677
+
678
+
679
+ def cmd_env_constants(args: argparse.Namespace) -> int:
680
+ repo = load_repo(args.repo)
681
+ if args.env and args.env not in repo.config.environments:
682
+ raise ChainError(f"unknown environment {args.env!r}")
683
+ table = constants_mod.constants_table(repo)
684
+ envs = [args.env] if args.env else list(repo.config.environments)
685
+ rows = {
686
+ name: {env: values[env] for env in envs}
687
+ for name, values in table.items()
688
+ if not args.missing or any(values[env] is None for env in envs)
689
+ }
690
+ if args.format == "json":
691
+ print(json.dumps(rows, indent=2, ensure_ascii=False))
692
+ elif not rows:
693
+ print("every constant has a value" if args.missing else "no constants defined")
694
+ else:
695
+ for name, values in rows.items():
696
+ shown = {e: v for e, v in values.items() if not args.missing or v is None}
697
+ cells = ", ".join(f"{e}={v if v is not None else '?'}" for e, v in shown.items())
698
+ print(f"{name:32} {repo.config.constants[name].spec:28} {cells}")
699
+ missing = any(v is None for values in rows.values() for v in values.values())
700
+ return EXIT_FINDINGS if args.missing and missing else EXIT_OK
701
+
702
+
703
+ def cmd_show(args: argparse.Namespace) -> int:
704
+ data = inspect_mod.show_node(load_repo(args.repo), args.node)
705
+ print(json.dumps(data, indent=2, ensure_ascii=False))
706
+ return EXIT_OK
707
+
708
+
709
+ def cmd_graph(args: argparse.Namespace) -> int:
710
+ graph = graph_mod.folder_graph(load_repo(args.repo), args.folder)
711
+ if args.format == "json":
712
+ print(json.dumps(graph.to_json(), indent=2, ensure_ascii=False))
713
+ elif args.format == "mermaid":
714
+ print(graph_mod.render_mermaid(graph), end="")
715
+ elif args.format == "dot":
716
+ print(graph_mod.render_dot(graph), end="")
717
+ else:
718
+ print(graph_mod.render_text(graph), end="")
719
+ return EXIT_OK
720
+
721
+
722
+ def cmd_links(args: argparse.Namespace) -> int:
723
+ if (args.node is None) == (args.name is None):
724
+ raise ChainError("links needs either a node (FOLDER/JOB) or --name <condition>")
725
+ links = graph_mod.load_links(load_repo(args.repo))
726
+ if args.name is not None:
727
+ if args.name not in links:
728
+ raise ChainError(f"no link named {args.name!r}")
729
+ selected = [links[args.name]]
730
+ else:
731
+ ref = NodeRef.parse(args.node)
732
+ selected = [
733
+ link
734
+ for link in links.values()
735
+ if ref in link.consumers
736
+ or ref in link.all_producers
737
+ or ref in link.deleters
738
+ or any(node == ref for node, _ in link.docond_delete)
739
+ ]
740
+ if args.format == "json":
741
+ print(json.dumps([link.to_json() for link in selected], indent=2, ensure_ascii=False))
742
+ return EXIT_OK
743
+ for link in selected:
744
+ producers = ", ".join(str(n) for n in link.all_producers) or "-"
745
+ consumers = ", ".join(str(n) for n in link.consumers) or "-"
746
+ standard = link.standard_name()
747
+ suffix = f" standard: {standard}" if standard and standard != link.name else ""
748
+ print(f"{link.kind:11} {link.name} {producers} -> {consumers}{suffix}")
749
+ return EXIT_OK
750
+
751
+
752
+ def _commit_option(parser: argparse.ArgumentParser) -> None:
753
+ parser.add_argument(
754
+ "--commit",
755
+ dest="commits",
756
+ action="append",
757
+ metavar="SHA",
758
+ help="only this commit of head (repeat it); the others are left out, cherry-pick style",
759
+ )
760
+
761
+
762
+ def cmd_take(args: argparse.Namespace) -> int:
763
+ repo = load_repo(args.repo)
764
+ merge_base = not args.no_merge_base
765
+ if args.list:
766
+ result = compare_mod.compare(
767
+ repo.root, args.base, args.head, use_merge_base=merge_base, commits=args.commits
768
+ )
769
+ print(f"Items {result.head.name} changed that can be taken into {result.base.name}:")
770
+ for item, label in take_mod.items_of(result):
771
+ print(f" {item} {label}")
772
+ return EXIT_OK
773
+ if args.apply:
774
+ workspace_mod.ensure_editable(repo)
775
+ try:
776
+ change = take_mod.take(
777
+ repo, args.base, args.head, args.item, theirs=args.theirs,
778
+ use_merge_base=merge_base, commits=args.commits,
779
+ ) # fmt: skip
780
+ except TakeConflict as conflict:
781
+ for c in conflict.conflicts:
782
+ print(
783
+ f"conflict {c['item']}: mine {take_mod.shown(c['mine'])}, before "
784
+ f"{take_mod.shown(c['before'])}, theirs {take_mod.shown(c['theirs'])} "
785
+ f"(take it with --theirs {c['item']}, or leave it out)",
786
+ file=sys.stderr,
787
+ )
788
+ raise
789
+ args.build = lambda _r, _a: change
790
+ return cmd_refactor(args)
791
+
792
+
793
+ def cmd_compare(args: argparse.Namespace) -> int:
794
+ head = WORKTREE if args.worktree else args.head
795
+ result = compare_mod.compare(
796
+ load_repo(args.repo).root,
797
+ args.base,
798
+ head,
799
+ use_merge_base=not args.no_merge_base,
800
+ commits=args.commits,
801
+ )
802
+ if args.format == "json":
803
+ print(json.dumps(result.to_json(), indent=2, ensure_ascii=False))
804
+ elif args.format == "md":
805
+ print(compare_mod.render_markdown(result), end="")
806
+ else:
807
+ print(compare_mod.render_text(result), end="")
808
+ return EXIT_OK
809
+
810
+
811
+ def _commit_count(selected: Sequence[str] | None, commits: Sequence[Any]) -> str:
812
+ total = sum(1 for c in commits if not c.merge)
813
+ if selected is None:
814
+ return _plural(len(commits), "commit")
815
+ return f"{len(selected)} of {_plural(total, 'commit')}"
816
+
817
+
818
+ def cmd_package(args: argparse.Namespace) -> int:
819
+ head = WORKTREE if args.worktree else args.head
820
+ package = package_mod.build_package(
821
+ load_repo(args.repo).root,
822
+ args.base,
823
+ head,
824
+ env=args.env,
825
+ commits=args.commits,
826
+ use_merge_base=not args.no_merge_base,
827
+ names=args.names,
828
+ jobnames=args.jobnames,
829
+ )
830
+ output = args.output or Path(package.filename)
831
+ if args.format == "json":
832
+ print(json.dumps(package.to_json(), indent=2, ensure_ascii=False))
833
+ else:
834
+ origin = f" (from merge base {package.merge_base[:7]})" if package.merge_base else ""
835
+ print(
836
+ f"Package {package.base.name}..{package.head.name} for {package.env}: "
837
+ f"{_plural(len(package.folders), 'folder')}, "
838
+ f"{_commit_count(package.selected, package.commits)}{origin}"
839
+ )
840
+ for folder in package.folders:
841
+ print(f" {folder.name} ({_plural(folder.jobs, 'job')}): {'; '.join(folder.reasons)}")
842
+ for warning in package.warnings:
843
+ print(f"warning: {warning}")
844
+ for finding in package.findings:
845
+ print(
846
+ f"new finding: {finding.severity} {finding.code} {finding.node}: {finding.message}"
847
+ )
848
+ report = sys.stderr if args.format == "json" else sys.stdout
849
+ if package.xml is None:
850
+ print(
851
+ f"Nothing to package: no folder changed between {package.base.name} and "
852
+ f"{package.head.name}.",
853
+ file=report,
854
+ )
855
+ elif not args.dry_run:
856
+ output.write_bytes(package.xml)
857
+ print(f"Wrote {output} ({package.size} bytes)", file=report)
858
+ return EXIT_OK
859
+
860
+
861
+ def cmd_serve(args: argparse.Namespace) -> int:
862
+ repo = load_repo(args.repo)
863
+ try:
864
+ from controlm_chain_admin.web.serve import run
865
+ from controlm_chain_admin.web.settings import Settings
866
+ except ImportError as exc: # pragma: no cover - depends on the installed extras
867
+ raise ChainError(
868
+ "the web server needs the [web] extra: pip install 'controlm-chain-admin[web]'"
869
+ ) from exc
870
+ settings = Settings(
871
+ repo=repo.root, host=args.host, port=args.port, open_browser=not args.no_browser
872
+ )
873
+ if settings.token is None: # CTMCHAIN_TOKEN keeps open tabs working across restarts
874
+ settings = settings.model_copy(update={"token": secrets.token_urlsafe(18)})
875
+ assert settings.token is not None
876
+ folders = len(folder_names(repo))
877
+ print(f"Serving {repo.config.application} ({_plural(folders, 'folder')}) at {settings.url}")
878
+ print(f"Editing enabled at {settings.editor_url(settings.token)}")
879
+ if args.check:
880
+ return EXIT_OK
881
+ run(settings) # pragma: no cover - blocks until interrupted
882
+ return EXIT_OK # pragma: no cover
883
+
884
+
885
+ # ---------------------------------------------------------------- entry point
886
+
887
+
888
+ def _refactor_options(parser: argparse.ArgumentParser) -> None:
889
+ parser.add_argument(
890
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
891
+ )
892
+ parser.add_argument("--apply", action="store_true", help="write the change (default: preview)")
893
+ parser.add_argument("--stat", action="store_true", help="list the files instead of the diff")
894
+ parser.add_argument("--format", choices=("text", "json"), default="text")
895
+
896
+
897
+ def _add_refactorings(commands: Any) -> None:
898
+ """Refactoring subcommands: every one previews a change set and writes only with --apply."""
899
+ rj = commands.add_parser("rename-job", help="rename a job; condition names follow")
900
+ rj.add_argument("node", metavar="FOLDER/JOB")
901
+ rj.add_argument("new_name", metavar="NEW_JOBNAME")
902
+ rj.add_argument("--keep-names", action="store_true", help="do not rename condition names")
903
+ rj.add_argument(
904
+ "--with-memname", action="store_true", help="rename MEMNAME when it equals JOBNAME"
905
+ )
906
+ _refactor_options(rj)
907
+ rj.set_defaults(
908
+ func=cmd_refactor,
909
+ build=lambda r, a: refactor_mod.rename_job(
910
+ r, a.node, a.new_name, update_names=not a.keep_names, with_memname=a.with_memname
911
+ ),
912
+ )
913
+
914
+ rf = commands.add_parser("rename-folder", help="rename a folder and everything that names it")
915
+ rf.add_argument("folder")
916
+ rf.add_argument("new_name", metavar="NEW_FOLDER_NAME")
917
+ rf.add_argument("--keep-names", action="store_true", help="do not rename condition names")
918
+ _refactor_options(rf)
919
+ rf.set_defaults(
920
+ func=cmd_refactor,
921
+ build=lambda r, a: refactor_mod.rename_folder(
922
+ r, a.folder, a.new_name, update_names=not a.keep_names
923
+ ),
924
+ )
925
+
926
+ rs = commands.add_parser("rename-subapp", help="rename a SUB_APPLICATION (one folder or all)")
927
+ rs.add_argument("old")
928
+ rs.add_argument("new")
929
+ rs.add_argument("--folder", help="only this folder")
930
+ _refactor_options(rs)
931
+ rs.set_defaults(
932
+ func=cmd_refactor,
933
+ build=lambda r, a: refactor_mod.rename_subapp(r, a.old, a.new, folder=a.folder),
934
+ )
935
+
936
+ mj = commands.add_parser("move-job", help="move a job to another folder")
937
+ mj.add_argument("node", metavar="FOLDER/JOB")
938
+ mj.add_argument("target", metavar="TARGET_FOLDER")
939
+ _refactor_options(mj)
940
+ mj.set_defaults(
941
+ func=cmd_refactor, build=lambda r, a: refactor_mod.move_job(r, a.node, a.target)
942
+ )
943
+
944
+ ij = commands.add_parser("insert-job", help="insert a new job between two linked jobs")
945
+ ij.add_argument("node", metavar="FOLDER/NEW_JOB")
946
+ ij.add_argument("--after", required=True, metavar="FOLDER/JOB", help="upstream job")
947
+ ij.add_argument("--before", required=True, metavar="FOLDER/JOB", help="downstream job")
948
+ ij.add_argument("--like", metavar="FOLDER/JOB", help="copy the attributes of this job")
949
+ ij.add_argument("--link", help="link id or name when several links join the two jobs")
950
+ _refactor_options(ij)
951
+ ij.set_defaults(
952
+ func=cmd_refactor,
953
+ build=lambda r, a: refactor_mod.insert_job(
954
+ r, a.node, after=a.after, before=a.before, like=a.like, link=a.link
955
+ ),
956
+ )
957
+
958
+ ev = commands.add_parser(
959
+ "extract-variable", help="move a literal of a command into a job variable"
960
+ )
961
+ ev.add_argument("target", metavar="FOLDER/JOB|FOLDER/")
962
+ ev.add_argument("--literal", required=True, help="text to move out of the attribute")
963
+ ev.add_argument(
964
+ "--as", dest="variable", required=True, help="variable name, e.g. %%%%DATASERVER"
965
+ )
966
+ ev.add_argument("--attribute", default="CMDLINE", help="attribute to edit (default: CMDLINE)")
967
+ _refactor_options(ev)
968
+ ev.set_defaults(
969
+ func=cmd_refactor,
970
+ build=lambda r, a: refactor_mod.extract_variable(
971
+ r, a.target, a.literal, a.variable, attribute=a.attribute
972
+ ),
973
+ )
974
+
975
+ se = commands.add_parser(
976
+ "set-exception", help="what one environment uses for a job or folder instead of the base"
977
+ )
978
+ se.add_argument("env")
979
+ se.add_argument("node", metavar="FOLDER/JOB|FOLDER/")
980
+ se.add_argument("--set", dest="sets", action="append", default=[], metavar="ATTR=VALUE")
981
+ se.add_argument(
982
+ "--unset", action="append", default=[], metavar="ATTR", help="leave it out there"
983
+ )
984
+ se.add_argument("--var", dest="vars", action="append", default=[], metavar="%%NAME=VALUE")
985
+ se.add_argument(
986
+ "--drop", action="append", default=[], metavar="NAME", help="back to the base value"
987
+ )
988
+ _refactor_options(se)
989
+ se.set_defaults(func=cmd_refactor, build=_build_exception)
990
+
991
+ rc = commands.add_parser("rename-constant", help="rename a constant everywhere it is used")
992
+ rc.add_argument("old")
993
+ rc.add_argument("new")
994
+ _refactor_options(rc)
995
+ rc.set_defaults(
996
+ func=cmd_refactor, build=lambda r, a: refactor_mod.rename_constant(r, a.old, a.new)
997
+ )
998
+
999
+
1000
+ def _pairs(items: Sequence[str], what: str, *, last: bool = False) -> dict[str, str]:
1001
+ """``KEY=VALUE`` items; split at the last ``=`` when keys may hold one."""
1002
+ pairs = {}
1003
+ for item in items:
1004
+ key, sep, value = item.rpartition("=") if last else item.partition("=")
1005
+ if not sep or not key:
1006
+ raise ChainError(f"expected {what}, got {item!r}")
1007
+ pairs[key] = value
1008
+ return pairs
1009
+
1010
+
1011
+ def _build_exception(repo: Any, args: argparse.Namespace) -> Any:
1012
+ attributes: dict[str, str | None] = dict(_pairs(args.sets, "ATTR=VALUE"))
1013
+ attributes.update(dict.fromkeys(args.unset))
1014
+ return refactor_mod.set_exception(
1015
+ repo, args.env, args.node, attributes, _pairs(args.vars, "%%NAME=VALUE"), args.drop
1016
+ )
1017
+
1018
+
1019
+ def _add_release(commands: Any) -> None:
1020
+ """``release``: where edits go, from a release branch to the trunk."""
1021
+ rel = commands.add_parser(
1022
+ "release", help="releases: start one from the trunk, mark installations, close it"
1023
+ )
1024
+ sub = rel.add_subparsers(dest="release_command", metavar="<subcommand>")
1025
+
1026
+ def command(name: str, help_text: str) -> argparse.ArgumentParser:
1027
+ parser: argparse.ArgumentParser = sub.add_parser(name, help=help_text)
1028
+ parser.add_argument(
1029
+ "--repo", type=Path, default=Path("."), help="repository directory (default: .)"
1030
+ )
1031
+ return parser
1032
+
1033
+ status = command("status", "the branch, whether it may be edited, releases and stashes")
1034
+ status.add_argument("--format", choices=("text", "json"), default="text")
1035
+ status.set_defaults(func=cmd_release_status)
1036
+ start = command("start", "create release/NAME from the trunk and switch to it")
1037
+ start.add_argument("name")
1038
+ start.add_argument("--stash", action="store_true", help="stash uncommitted changes first")
1039
+ start.set_defaults(func=cmd_release_start)
1040
+ switch = command("switch", "switch to another branch (local, or on the remote)")
1041
+ switch.add_argument("branch")
1042
+ switch.add_argument("--stash", action="store_true", help="stash uncommitted changes first")
1043
+ switch.set_defaults(func=cmd_release_switch)
1044
+ installed = command("installed", "record that a release is installed in an environment")
1045
+ installed.add_argument("env")
1046
+ installed.add_argument("--release", help="release name (default: the checked-out one)")
1047
+ installed.add_argument("--commit", help="the installed commit (default: the release tip)")
1048
+ installed.set_defaults(func=cmd_release_installed)
1049
+ update = command("update", "merge the trunk into the checked-out release")
1050
+ update.set_defaults(func=cmd_release_update)
1051
+ close = command("close", "merge the release into the trunk once installed in production")
1052
+ close.set_defaults(func=cmd_release_close)
1053
+ rel.set_defaults(func=cmd_release_help)
1054
+
1055
+
1056
+ def cmd_release_help(args: argparse.Namespace) -> int:
1057
+ print("usage: ctmchain release {status,start,switch,installed,update,close} [--repo DIR]")
1058
+ return EXIT_USAGE
1059
+
1060
+
1061
+ def _release_line(release: workspace_mod.Release, state: workspace_mod.Workspace) -> str:
1062
+ parts = [release.branch, f"{release.ahead} ahead of {state.trunk}"]
1063
+ if release.trunk_behind:
1064
+ parts[-1] += f", {release.trunk_behind} behind"
1065
+ installed = {i.env: i for i in release.installed}
1066
+ for env in state.environments:
1067
+ mark = installed.get(env)
1068
+ text = (
1069
+ mark.tag + (f" ({mark.behind} behind)" if mark and mark.behind else "")
1070
+ if mark
1071
+ else "-"
1072
+ )
1073
+ parts.append(f"{env}: {text}")
1074
+ if not release.local:
1075
+ parts.append("(on the remote only)")
1076
+ if release.closable:
1077
+ parts.append("ready to close")
1078
+ return " " + " ".join(parts)
1079
+
1080
+
1081
+ def cmd_release_status(args: argparse.Namespace) -> int:
1082
+ state = workspace_mod.workspace(load_repo(args.repo))
1083
+ if args.format == "json":
1084
+ print(json.dumps(state.to_json(), indent=2, ensure_ascii=False))
1085
+ return EXIT_OK
1086
+ where = "editable" if state.editable else f"read-only: {state.reason}"
1087
+ changes = _plural(state.changes, "uncommitted chain file")
1088
+ print(f"On {state.branch or 'a detached HEAD'} · {where} · {changes}")
1089
+ remote = state.remote or "none"
1090
+ if state.upstream:
1091
+ remote += f" ({state.upstream}: {state.ahead} ahead, {state.behind} behind)"
1092
+ print(f"Trunk: {state.trunk} · production: {state.production} · remote: {remote}")
1093
+ print("Releases:" if state.releases else "No release.")
1094
+ for release in state.releases:
1095
+ print(_release_line(release, state))
1096
+ if state.stashes:
1097
+ print("Stashes:")
1098
+ for saved in state.stashes:
1099
+ print(f" {saved.ref} on {saved.branch or '?'}: {saved.message}")
1100
+ return EXIT_OK
1101
+
1102
+
1103
+ def cmd_release_start(args: argparse.Namespace) -> int:
1104
+ print(workspace_mod.start_release(load_repo(args.repo), args.name, stash=args.stash))
1105
+ return EXIT_OK
1106
+
1107
+
1108
+ def cmd_release_switch(args: argparse.Namespace) -> int:
1109
+ print(workspace_mod.switch(load_repo(args.repo), args.branch, stash=args.stash))
1110
+ return EXIT_OK
1111
+
1112
+
1113
+ def cmd_release_installed(args: argparse.Namespace) -> int:
1114
+ repo = load_repo(args.repo)
1115
+ done = workspace_mod.mark_installed(repo, args.env, args.release, args.commit)
1116
+ branch = repo.config.git_rules.release_prefix + (
1117
+ args.release or workspace_mod.workspace(repo).release or "?"
1118
+ )
1119
+ print(f"Tagged {done.tag} on {done.sha[:7]}: {branch} in {done.env}")
1120
+ return EXIT_OK
1121
+
1122
+
1123
+ def cmd_release_update(args: argparse.Namespace) -> int:
1124
+ print(workspace_mod.update_from_trunk(load_repo(args.repo)))
1125
+ return EXIT_OK
1126
+
1127
+
1128
+ def cmd_release_close(args: argparse.Namespace) -> int:
1129
+ print(workspace_mod.close_release(load_repo(args.repo)))
1130
+ return EXIT_OK
1131
+
1132
+
1133
+ def cmd_commit(args: argparse.Namespace) -> int:
1134
+ root = load_repo(args.repo).root
1135
+ state = commit_mod.pending(root)
1136
+ if not state.files:
1137
+ print("Nothing to commit: the chain files have no change.")
1138
+ return EXIT_OK
1139
+ message = args.message or state.message
1140
+ if args.dry_run:
1141
+ print(f"On {state.branch or 'a detached HEAD'}: {_plural(len(state.files), 'file')}")
1142
+ for path in state.files:
1143
+ print(f" {path}")
1144
+ print()
1145
+ print(message)
1146
+ print()
1147
+ print("Nothing committed (dry run).")
1148
+ return EXIT_OK
1149
+ done = commit_mod.commit(root, message, state.fingerprint, branch=args.branch)
1150
+ print(f"Committed {done.sha[:7]} on {done.branch}: {done.subject}")
1151
+ return EXIT_OK
1152
+
1153
+
1154
+ def cmd_resolve(args: argparse.Namespace) -> int:
1155
+ repo = load_repo(args.repo)
1156
+ findings = validate(repo).findings
1157
+ fixes = fixes_mod.fixes_for(repo, findings)
1158
+ if not args.choices:
1159
+ codes: dict[str, list[tuple[fixes_mod.Fix, ...]]] = {}
1160
+ for finding, offered in zip(findings, fixes, strict=True):
1161
+ codes.setdefault(finding.code, []).append(offered)
1162
+ for code, groups in codes.items():
1163
+ kinds: dict[str, tuple[str, int]] = {}
1164
+ for offered in groups:
1165
+ for fix in offered:
1166
+ title, count = kinds.get(fix.kind, (fix.title, 0))
1167
+ single = sum(1 for f in offered if f.kind == fix.kind) == 1
1168
+ kinds[fix.kind] = (title, count + (1 if single else 0))
1169
+ head = f"{code} ({_plural(len(groups), 'finding')})"
1170
+ print(head + ("" if kinds else ": no fix"))
1171
+ for kind, (title, count) in kinds.items():
1172
+ print(f" {kind}: {title} ({count})")
1173
+ return EXIT_OK
1174
+ choice: dict[str, str] = {}
1175
+ for item in args.choices:
1176
+ code, sep, kind = item.partition("=")
1177
+ if not sep or not code or not kind:
1178
+ print(f"ctmchain: error: expected CODE=KIND, got {item!r}", file=sys.stderr)
1179
+ return EXIT_USAGE
1180
+ choice[code] = kind
1181
+ changes, skipped = fixes_mod.by_kind(findings, fixes, choice)
1182
+ if not changes:
1183
+ print("Nothing to change.")
1184
+ status = EXIT_OK
1185
+ else:
1186
+ args.build = lambda r, _a: refactor_mod.run_operation(r, "batch", {"changes": changes})
1187
+ status = cmd_refactor(args)
1188
+ for finding in skipped:
1189
+ print(f"skipped: {finding.code} {finding.name} (no single {choice[finding.code]} fix)")
1190
+ return status
1191
+
1192
+
1193
+ def cmd_refactor(args: argparse.Namespace) -> int:
1194
+ repo = load_repo(args.repo)
1195
+ if args.apply:
1196
+ workspace_mod.ensure_editable(repo)
1197
+ change = args.build(repo, args)
1198
+ result = refactor_mod.apply(repo, change) if args.apply and change.edits else None
1199
+ if args.format == "json":
1200
+ print(
1201
+ json.dumps(
1202
+ {**change.to_json(), "applied": result is not None}, indent=2, ensure_ascii=False
1203
+ )
1204
+ )
1205
+ return EXIT_OK
1206
+ print(change.summary)
1207
+ for warning in change.warnings:
1208
+ print(f"warning: {warning}")
1209
+ if not change.edits:
1210
+ print("Nothing to change.")
1211
+ return EXIT_OK
1212
+ print(change.stat() if args.stat else change.diff(), end="")
1213
+ if result is not None:
1214
+ print(f"Applied: {result.written} written, {result.deleted} deleted")
1215
+ else:
1216
+ print(f"Preview only: {_plural(len(change.edits), 'file')}; re-run with --apply to write.")
1217
+ return EXIT_OK
1218
+
1219
+
1220
+ def main(argv: Sequence[str] | None = None) -> int:
1221
+ """Parse ``argv`` and dispatch to one core operation. Returns the process exit code."""
1222
+ parser = build_parser()
1223
+ args = parser.parse_args(argv)
1224
+ if args.command is None:
1225
+ parser.print_help()
1226
+ return EXIT_USAGE
1227
+ try:
1228
+ result: int = args.func(args)
1229
+ except ChainError as exc:
1230
+ print(f"ctmchain: error: {exc}", file=sys.stderr)
1231
+ return EXIT_ERROR
1232
+ except BrokenPipeError: # e.g. `ctmchain validate | head`: the reader went away
1233
+ devnull = os.open(os.devnull, os.O_WRONLY)
1234
+ os.dup2(devnull, sys.stdout.fileno())
1235
+ return EXIT_OK
1236
+ except OSError as exc:
1237
+ print(f"ctmchain: error: {exc}", file=sys.stderr)
1238
+ return EXIT_ERROR
1239
+ return result