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.
- controlm_chain_admin/__init__.py +3 -0
- controlm_chain_admin/__main__.py +10 -0
- controlm_chain_admin/cli.py +1239 -0
- controlm_chain_admin/core/__init__.py +5 -0
- controlm_chain_admin/core/attribute_groups.py +61 -0
- controlm_chain_admin/core/commit.py +243 -0
- controlm_chain_admin/core/compare.py +806 -0
- controlm_chain_admin/core/constants.py +371 -0
- controlm_chain_admin/core/errors.py +131 -0
- controlm_chain_admin/core/fixes.py +433 -0
- controlm_chain_admin/core/graph.py +157 -0
- controlm_chain_admin/core/guide.py +76 -0
- controlm_chain_admin/core/history.py +335 -0
- controlm_chain_admin/core/inspect.py +105 -0
- controlm_chain_admin/core/intake.py +1148 -0
- controlm_chain_admin/core/jsonmodel.py +253 -0
- controlm_chain_admin/core/letter.py +169 -0
- controlm_chain_admin/core/links.py +299 -0
- controlm_chain_admin/core/membership.py +36 -0
- controlm_chain_admin/core/model.py +67 -0
- controlm_chain_admin/core/ops.py +656 -0
- controlm_chain_admin/core/overrides.py +113 -0
- controlm_chain_admin/core/package.py +318 -0
- controlm_chain_admin/core/refactor.py +1301 -0
- controlm_chain_admin/core/relations.py +194 -0
- controlm_chain_admin/core/repo.py +339 -0
- controlm_chain_admin/core/selection.py +189 -0
- controlm_chain_admin/core/take.py +567 -0
- controlm_chain_admin/core/validate.py +454 -0
- controlm_chain_admin/core/workspace.py +592 -0
- controlm_chain_admin/core/xmlio.py +151 -0
- controlm_chain_admin/py.typed +0 -0
- controlm_chain_admin/web/__init__.py +1 -0
- controlm_chain_admin/web/app.py +136 -0
- controlm_chain_admin/web/deps.py +27 -0
- controlm_chain_admin/web/errors.py +82 -0
- controlm_chain_admin/web/routers/__init__.py +1 -0
- controlm_chain_admin/web/routers/changes.py +55 -0
- controlm_chain_admin/web/routers/environments.py +26 -0
- controlm_chain_admin/web/routers/folders.py +100 -0
- controlm_chain_admin/web/routers/history.py +136 -0
- controlm_chain_admin/web/routers/intake.py +31 -0
- controlm_chain_admin/web/routers/jobs.py +63 -0
- controlm_chain_admin/web/routers/package.py +77 -0
- controlm_chain_admin/web/routers/repo.py +27 -0
- controlm_chain_admin/web/routers/table.py +17 -0
- controlm_chain_admin/web/routers/validate.py +36 -0
- controlm_chain_admin/web/routers/workspace.py +127 -0
- controlm_chain_admin/web/schemas.py +525 -0
- controlm_chain_admin/web/serve.py +23 -0
- controlm_chain_admin/web/settings.py +30 -0
- controlm_chain_admin/web/static/assets/graph-CA0VXlpl.js +31 -0
- controlm_chain_admin/web/static/assets/index-BA0sPihz.css +1 -0
- controlm_chain_admin/web/static/assets/index-DYc-B93N.js +28 -0
- controlm_chain_admin/web/static/assets/react-DB7G9u3x.js +28 -0
- controlm_chain_admin/web/static/index.html +16 -0
- controlm_chain_admin-0.1.0.dist-info/METADATA +186 -0
- controlm_chain_admin-0.1.0.dist-info/RECORD +61 -0
- controlm_chain_admin-0.1.0.dist-info/WHEEL +4 -0
- controlm_chain_admin-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|