galaxy-tool-refactor-cli 0.2.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.
@@ -0,0 +1,22 @@
1
+ """Top-level CLI app for the Galaxy tool refactoring framework.
2
+
3
+ Tier 4 (the app layer): a thin front-end over the registry facade. Orchestration
4
+ lives in the tier-3.6 facade (``galaxy-tool-refactor-registry``), which composes
5
+ the lower tiers; this package depends on that facade plus fmt's ``cli_support``
6
+ file-walking engine (tier 3) and tier-1 parsing — **not** on the codemod tier
7
+ directly (cli `docs/decisions.md` D4). It exposes the ``galaxy-tool-refactor`` CLI
8
+ with seven commands:
9
+
10
+ - ``format`` — structural canonicalisation + cosmetic formatting (safe,
11
+ idempotent; never changes ``profile=``).
12
+ - ``upgrade`` — opt-in repair + profile upgrade to the latest reachable profile.
13
+ - ``check`` — report-only linter over the selected rules' detect phases.
14
+ - ``find-references`` — read-only query: a parameter's Cheetah ``$var`` reference
15
+ sites across a tool's templated sections (cli §D8).
16
+ - ``rulesets`` / ``rules`` — introspect the baked-in rulesets and rules.
17
+ - ``normalize-macros`` — opt-in, repo-scoped: lowercase literal ``format``/``ftype``
18
+ in ``<macros>``-root files (never part of ``format``/``upgrade``; cli §D7).
19
+
20
+ Per dignified-python there are no re-exports; import from
21
+ ``galaxy_tool_refactor_cli.cli`` directly.
22
+ """
@@ -0,0 +1,1144 @@
1
+ """The ``galaxy-tool-refactor`` command-line interface.
2
+
3
+ Ten subcommands (including the two opt-in conversions, ``convert-help`` and
4
+ ``tokenize-version``). ``format`` and ``upgrade`` share fmt's file-walking /
5
+ drift-detection engine (``galaxy_tool_fmt.cli_support``) and differ only in
6
+ which rules run before serialisation; ``check`` is a report-only linter that
7
+ mutates nothing; ``find-references`` is a read-only query for a parameter's Cheetah
8
+ ``$var`` reference sites and ``rename-param`` is its mutating sibling (rename a
9
+ parameter across those sites); ``rules`` / ``rulesets`` print the available baked-in
10
+ rules and rulesets; ``normalize-macros`` is a separate, opt-in pass over macro-library
11
+ files. All rule orchestration is delegated to the tier-3.6 registry facade
12
+ (``galaxy_tool_refactor_registry``); this module only does CLI plumbing.
13
+
14
+ - ``format`` — apply a ruleset's (or a ``--select``/``--ignore`` selection's)
15
+ fixable rules then cosmetic formatting. Safe and idempotent; never changes
16
+ ``profile=``. Default ruleset ``default`` reproduces the historical behaviour.
17
+ Macro-library files (``<macros>`` root) are also cosmetically formatted
18
+ (kind-applicable rules only — no codemods).
19
+ - ``upgrade`` — repair, then iteratively upgrade ``profile=`` toward the latest
20
+ (applying the registered migration each step), then format. Opt-in and
21
+ semantic; rulesets do not apply (``--select``/``--ignore`` adjust its rule set).
22
+ Also bumps an imported ``@PROFILE@`` token in place when every profile-using
23
+ importer in the run agrees on the target (else reports and skips); a
24
+ ``profile="@TOKEN@"`` whose token is inline is handled per-file by GTR007.
25
+ - ``check`` — report where tools deviate from the selection, one
26
+ ``file:line CODE message`` per finding, without changing anything. Fixable
27
+ findings fail the run; advisory (``detect_only``) findings are informational
28
+ unless ``--strict``. Macro files are checked for cosmetic (fixable) drift too.
29
+ - ``find-references`` — read-only query: print every Cheetah ``$NAME`` reference site
30
+ (``file:line [section] $ref``) across a tool's templated sections. Mutates nothing,
31
+ not a rule (no selection); the first read-only consumer of the Cheetah reference model
32
+ (``galaxy_tool_source.cheetah_refs``). See ``docs/decisions.md`` §D8.
33
+ - ``rename-param`` — the mutating sibling of ``find-references``: rename a parameter
34
+ OLD to NEW across every Cheetah section, by-name cross-reference attribute, and
35
+ ``<tests>`` mirror, plus the definition. Atomic per file (rewrites everything or skips
36
+ with a reason); ``--check`` previews. Built on the faithful CDM lexer (M5.3); see
37
+ ``docs/decisions.md`` §D9.
38
+ - ``rules`` / ``rulesets`` — introspection: the baked-in rules and the rulesets.
39
+ - ``normalize-macros`` — opt-in, repo-scoped: lowercase literal ``format`` /
40
+ ``ftype`` in ``<macros>``-root files (the macro-library analog of the 24.2
41
+ normalization the per-tool ``upgrade`` cannot reach — a value defined in an
42
+ imported macro file). It rewrites files other than the one named (a shared
43
+ macro file affects every importer), so it is never folded into ``format`` /
44
+ ``upgrade``; see ``galaxy-tool-codemod/docs/macro-aware-normalization.md``.
45
+
46
+ Selection (``--ruleset`` / ``--select`` / ``--ignore``) is shared by ``format``,
47
+ ``upgrade`` (no ``--ruleset``), and ``check``; precedence is ruff-style
48
+ (``--ignore`` ▸ ``--select`` ▸ ``--ruleset``, where ``--ruleset`` unions the named
49
+ sets and ``--select`` replaces them).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import sys
55
+ from collections import defaultdict
56
+ from collections.abc import Mapping
57
+ from pathlib import Path
58
+
59
+ import click
60
+ from galaxy_tool_fmt.cli_support import (
61
+ Action,
62
+ RunOptions,
63
+ TransformOutcome,
64
+ is_macros_root,
65
+ is_tool_root,
66
+ iter_targets,
67
+ make_backup,
68
+ run,
69
+ )
70
+ from galaxy_tool_fmt.detect import detect_macro_document
71
+ from galaxy_tool_fmt.format import format_macro_document
72
+ from galaxy_tool_refactor_registry import facade
73
+ from galaxy_tool_refactor_registry.bundle_rename import (
74
+ BundleRenameResult,
75
+ ConsensusRenameResult,
76
+ build_importer_map,
77
+ find_references_in_bundle,
78
+ rename_param_bundle,
79
+ rename_param_consensus,
80
+ )
81
+ from galaxy_tool_refactor_registry.errors import UnknownRuleCode, UnknownRuleset
82
+ from galaxy_tool_refactor_registry.macro_datatype import normalize_macro_files
83
+ from galaxy_tool_refactor_registry.macro_profile import (
84
+ apply_profile_token_plans,
85
+ plan_from_sites,
86
+ profile_token_site,
87
+ )
88
+ from galaxy_tool_refactor_registry.registry import display_code
89
+ from galaxy_tool_refactor_registry.resolve import (
90
+ resolve_codes,
91
+ resolve_upgrade_codes,
92
+ )
93
+ from galaxy_tool_source.binding import ToolXmlSyntaxError, load_macros, load_tool
94
+ from galaxy_tool_source.document import MacroDocument, ToolDocument
95
+
96
+ _PATH_ARGUMENT = click.argument(
97
+ "paths",
98
+ nargs=-1,
99
+ required=True,
100
+ type=click.Path(exists=True, path_type=Path),
101
+ )
102
+ _CHECK_OPTION = click.option(
103
+ "--check",
104
+ is_flag=True,
105
+ help="Don't write files. Exit non-zero if any file would change.",
106
+ )
107
+ _DIFF_OPTION = click.option(
108
+ "--diff",
109
+ is_flag=True,
110
+ help="Don't write files. Print a unified diff of the rewrite to stdout.",
111
+ )
112
+ _QUIET_OPTION = click.option(
113
+ "-q",
114
+ "--quiet",
115
+ is_flag=True,
116
+ help="Suppress per-file output; only errors and the summary are shown.",
117
+ )
118
+ _REPO_ROOT_OPTION = click.option(
119
+ "--repo-root",
120
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
121
+ default=None,
122
+ help=(
123
+ "Repo directory used to prove a macro file is sole-owned before a rename "
124
+ "edits it. Required only when a rename's references reach an imported macro."
125
+ ),
126
+ )
127
+ _BACKUP_OPTION = click.option(
128
+ "--backup",
129
+ is_flag=True,
130
+ help="Before overwriting a file, copy its current content to <file>.bak.",
131
+ )
132
+ _ACROSS_IMPORTERS_OPTION = click.option(
133
+ "--across-importers",
134
+ is_flag=True,
135
+ help=(
136
+ "When a rename reaches a macro shared by other tools, rename the parameter "
137
+ "across all of its importers in lockstep (only if they all agree). Needs "
138
+ "--repo-root."
139
+ ),
140
+ )
141
+ _STRICT_OPTION = click.option(
142
+ "--strict",
143
+ is_flag=True,
144
+ help="Also fail (exit non-zero) on advisory findings, not just fixable ones.",
145
+ )
146
+ _RULESET_OPTION = click.option(
147
+ "--ruleset",
148
+ "rulesets",
149
+ multiple=True,
150
+ metavar="NAME",
151
+ help="Rule-set(s) to apply/report — the UNION of the named sets "
152
+ "(cosmetic | default | iuc | strict). Repeatable or comma-separated, "
153
+ "e.g. --ruleset default,strict. Default: default. "
154
+ "See `galaxy-tool-refactor rulesets`.",
155
+ )
156
+ _SELECT_OPTION = click.option(
157
+ "--select",
158
+ "select",
159
+ multiple=True,
160
+ metavar="CODE",
161
+ help="Run only these rules — GTR codes or planemo linter names "
162
+ "(replaces the ruleset's set). Repeatable or comma-separated, "
163
+ "e.g. --select GTR001,HelpMissing.",
164
+ )
165
+ _IGNORE_OPTION = click.option(
166
+ "--ignore",
167
+ "ignore",
168
+ multiple=True,
169
+ metavar="CODE",
170
+ help="Drop these rules — GTR codes or planemo linter names — from the "
171
+ "selection. Repeatable or comma-separated.",
172
+ )
173
+
174
+
175
+ def _split_codes(values: tuple[str, ...]) -> tuple[str, ...]:
176
+ """Flatten repeated / comma-separated select/ignore tokens, stripped.
177
+
178
+ Case is preserved (the resolver matches GTR codes and planemo linter names
179
+ case-insensitively) so an error message echoes the token the user typed.
180
+ """
181
+ codes: list[str] = []
182
+ for value in values:
183
+ codes.extend(token.strip() for token in value.split(",") if token.strip())
184
+ return tuple(codes)
185
+
186
+
187
+ def _split_names(values: tuple[str, ...]) -> tuple[str, ...]:
188
+ """Flatten repeated / comma-separated ruleset names, lower-cased and stripped."""
189
+ names: list[str] = []
190
+ for value in values:
191
+ names.extend(
192
+ token.strip().lower() for token in value.split(",") if token.strip()
193
+ )
194
+ return tuple(names)
195
+
196
+
197
+ def _resolve(
198
+ *, rulesets: tuple[str, ...], select: tuple[str, ...], ignore: tuple[str, ...]
199
+ ) -> frozenset[str]:
200
+ """Resolve a format/check selection, mapping facade errors to the CLI boundary."""
201
+ try:
202
+ return resolve_codes(
203
+ rulesets=_split_names(rulesets),
204
+ select=_split_codes(select),
205
+ ignore=_split_codes(ignore),
206
+ )
207
+ except (UnknownRuleset, UnknownRuleCode) as error:
208
+ raise click.BadParameter(str(error)) from error
209
+
210
+
211
+ def _resolve_upgrade(
212
+ *, select: tuple[str, ...], ignore: tuple[str, ...]
213
+ ) -> frozenset[str]:
214
+ """Resolve an upgrade selection (no ruleset), mapping facade errors to the CLI."""
215
+ try:
216
+ return resolve_upgrade_codes(
217
+ select=_split_codes(select), ignore=_split_codes(ignore)
218
+ )
219
+ except UnknownRuleCode as error:
220
+ raise click.BadParameter(str(error)) from error
221
+
222
+
223
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
224
+ def main() -> None:
225
+ """Refactor Galaxy tool XML: structural codemods plus cosmetic formatting."""
226
+
227
+
228
+ @main.command(name="format")
229
+ @_PATH_ARGUMENT
230
+ @_CHECK_OPTION
231
+ @_DIFF_OPTION
232
+ @_QUIET_OPTION
233
+ @_BACKUP_OPTION
234
+ @_RULESET_OPTION
235
+ @_SELECT_OPTION
236
+ @_IGNORE_OPTION
237
+ def format_command(
238
+ paths: tuple[Path, ...],
239
+ check: bool,
240
+ diff: bool,
241
+ quiet: bool,
242
+ backup: bool,
243
+ rulesets: tuple[str, ...],
244
+ select: tuple[str, ...],
245
+ ignore: tuple[str, ...],
246
+ ) -> None:
247
+ """Apply a ruleset's fixable rules then cosmetic formatting (never ``profile=``).
248
+
249
+ The default ruleset ``default`` applies the canonical codemods (typo repair,
250
+ attribute / element order) and the cosmetic rules — the historical ``format``
251
+ behaviour. Advisory rules in a selection (e.g. under ``--ruleset strict``) are
252
+ reported as notes but never change a file. Macro-library files (``<macros>``
253
+ root) are also **cosmetically** formatted (the kind-applicable rules — no
254
+ codemods, which are tool-only; rule selection governs tools). PATHS may be
255
+ files or directories (searched recursively for ``*.xml``); other XML is
256
+ skipped.
257
+ """
258
+ codes = _resolve(rulesets=rulesets, select=select, ignore=ignore)
259
+
260
+ def transform(document: ToolDocument) -> TransformOutcome:
261
+ result = facade.run(document, codes=codes)
262
+ return TransformOutcome(result.formatted, notes=result.notes)
263
+
264
+ def macro_transform(document: MacroDocument) -> TransformOutcome:
265
+ # Macro files get cosmetic formatting only; codemods are tool-only.
266
+ return TransformOutcome(format_macro_document(document))
267
+
268
+ exit_code = run(
269
+ paths,
270
+ transform=transform,
271
+ action=Action(past="reformatted", conditional="would reformat"),
272
+ options=RunOptions(check=check, diff=diff, quiet=quiet, backup=backup),
273
+ macro_transform=macro_transform,
274
+ )
275
+ sys.exit(exit_code)
276
+
277
+
278
+ @main.command(name="upgrade")
279
+ @_PATH_ARGUMENT
280
+ @_CHECK_OPTION
281
+ @_DIFF_OPTION
282
+ @_QUIET_OPTION
283
+ @_BACKUP_OPTION
284
+ @_RULESET_OPTION
285
+ @_SELECT_OPTION
286
+ @_IGNORE_OPTION
287
+ def upgrade_command(
288
+ paths: tuple[Path, ...],
289
+ check: bool,
290
+ diff: bool,
291
+ quiet: bool,
292
+ backup: bool,
293
+ rulesets: tuple[str, ...],
294
+ select: tuple[str, ...],
295
+ ignore: tuple[str, ...],
296
+ ) -> None:
297
+ """Repair and upgrade tools to the latest profile they can reach, then format.
298
+
299
+ Opt-in and semantic. The profile upgrade always runs; ``--select`` / ``--ignore``
300
+ adjust the *other* fixable rules (by default typo repair + cosmetic
301
+ formatting) — e.g. ``--ignore GTR006`` upgrades without typo repair. Rulesets
302
+ are a ``format``/``check`` concept and are **not** accepted here.
303
+
304
+ A ``profile="@PROFILE@"`` whose token lives in an *imported* macro file is
305
+ upgraded by bumping that token in place — but only when every profile-using
306
+ importer in this run agrees on the target profile; a macro file whose
307
+ importers disagree is reported and left untouched (no over-declaration). The
308
+ inline-token case is handled per-file by GTR007. The token value is the *only*
309
+ semantic edit, but the macro file it lives in **is** reserialised through fmt's
310
+ ``format_macro_document`` when the token is bumped (so a bumped file is also
311
+ cosmetically normalised — GTR001/GTR004); ``upgrade`` runs no *separate*
312
+ cosmetic macro pass over un-bumped macro files the way ``format`` does. PATHS
313
+ may be files or directories.
314
+
315
+ The upgrade is structural, not behaviour-preserving: bumping ``profile=`` opts
316
+ the tool into newer Galaxy runtime defaults the XSD can't verify. When a bump
317
+ crosses such a boundary (e.g. ``set -e``, Python 3, optional-value templating),
318
+ a note lists the crossed versions to review — see ``docs/profile_upgrades.md``.
319
+ A few of those changes have a safe mechanical fix that is **applied
320
+ automatically** once the reached profile crosses them (e.g. stripping
321
+ whitespace from ``from_work_dir`` at 21.09); the rest are warn-only.
322
+ """
323
+ if rulesets:
324
+ raise click.BadParameter(
325
+ "--ruleset is not applicable to 'upgrade'; rulesets govern "
326
+ "'format' / 'check'. Use --select / --ignore to adjust the rule set.",
327
+ param_hint="--ruleset",
328
+ )
329
+ codes = _resolve_upgrade(select=select, ignore=ignore)
330
+
331
+ # Whole-run phase first: bump imported @PROFILE@ tokens where every
332
+ # profile-using importer agrees on the target (the inline case is handled
333
+ # per-file by GTR007 in the transform below). This edits *macro* files, so it
334
+ # cannot ride the per-file tool transform.
335
+ macro_pending = _upgrade_macro_profile_tokens(
336
+ paths, check=check, diff=diff, quiet=quiet, backup=backup
337
+ )
338
+
339
+ def transform(document: ToolDocument) -> TransformOutcome:
340
+ result = facade.upgrade(document, codes=codes)
341
+ return TransformOutcome(result.formatted, notes=result.notes)
342
+
343
+ exit_code = run(
344
+ paths,
345
+ transform=transform,
346
+ action=Action(past="upgraded", conditional="would upgrade"),
347
+ options=RunOptions(check=check, diff=diff, quiet=quiet, backup=backup),
348
+ )
349
+ # A pending macro-token bump is a "would change" under either preview mode
350
+ # (--check or --diff), so both must surface it in the exit code (cli D6).
351
+ sys.exit(exit_code or (1 if ((check or diff) and macro_pending) else 0))
352
+
353
+
354
+ def _upgrade_macro_profile_tokens(
355
+ paths: tuple[Path, ...], *, check: bool, diff: bool, quiet: bool, backup: bool
356
+ ) -> bool:
357
+ """Upgrade imported ``@PROFILE@`` tokens across the run; return would-edit.
358
+
359
+ Walks the run's tool files, collects each one's imported-profile-token site,
360
+ and for every macro file whose profile-using importers agree on a target
361
+ bumps the ``<token>`` in place (writing unless ``check``/``diff``). A macro
362
+ file whose importers disagree is reported and left untouched. Returns whether
363
+ any macro file was (or, under preview, would be) edited — the caller folds
364
+ that into the ``--check`` exit code.
365
+ """
366
+ sites = []
367
+ for path in iter_targets(paths):
368
+ try:
369
+ original = path.read_bytes()
370
+ except OSError:
371
+ continue
372
+ if not is_tool_root(original):
373
+ continue
374
+ try:
375
+ document = load_tool(path) # load from path so imports resolve
376
+ except ToolXmlSyntaxError:
377
+ continue # malformed tools are surfaced by the per-file run() below
378
+ site = profile_token_site(document)
379
+ if site is not None:
380
+ sites.append(site)
381
+ plans = plan_from_sites(sites)
382
+ result = apply_profile_token_plans(
383
+ plans, write=not (check or diff), backup=backup
384
+ )
385
+ if not quiet:
386
+ verb = "would upgrade" if (check or diff) else "upgraded"
387
+ for edit in result.edits:
388
+ click.echo(
389
+ f"{verb} {edit.token_name} {edit.old_value} -> {edit.new_value} "
390
+ f"in {edit.macro_file} ({len(edit.importers)} tool(s))"
391
+ )
392
+ for skip in result.skips:
393
+ click.echo(
394
+ f"skipped {skip.macro_file}: {skip.token_name} importers disagree "
395
+ f"on target profile ({len(skip.importers)} tool(s))"
396
+ )
397
+ return bool(result.edits)
398
+
399
+
400
+ def _check_summary(
401
+ *,
402
+ fixable: int,
403
+ advisory: int,
404
+ flagged: int,
405
+ clean: int,
406
+ skipped: int,
407
+ errored: int,
408
+ ) -> str:
409
+ """Render the trailing summary line for ``check``."""
410
+ parts: list[str] = []
411
+ if fixable or advisory:
412
+ counts = []
413
+ if fixable:
414
+ counts.append(f"{fixable} fixable")
415
+ if advisory:
416
+ counts.append(f"{advisory} advisory")
417
+ parts.append(", ".join(counts) + f" finding(s) in {flagged} file(s)")
418
+ if clean:
419
+ parts.append(f"{clean} file(s) clean")
420
+ if skipped:
421
+ parts.append(f"{skipped} skipped (not a Galaxy tool or macro file)")
422
+ if errored:
423
+ parts.append(f"{errored} errored")
424
+ return "; ".join(parts) + "." if parts else "no files checked."
425
+
426
+
427
+ @main.command(name="check")
428
+ @_PATH_ARGUMENT
429
+ @_QUIET_OPTION
430
+ @_STRICT_OPTION
431
+ @_RULESET_OPTION
432
+ @_SELECT_OPTION
433
+ @_IGNORE_OPTION
434
+ def check_command(
435
+ paths: tuple[Path, ...],
436
+ quiet: bool,
437
+ strict: bool,
438
+ rulesets: tuple[str, ...],
439
+ select: tuple[str, ...],
440
+ ignore: tuple[str, ...],
441
+ ) -> None:
442
+ """Report where tools deviate from the selection, without changing them.
443
+
444
+ Runs the selected rules' detect phases (default ruleset ``default``): *fixable*
445
+ (GTR — what ``format`` would change) and, under ``--ruleset strict``, the
446
+ *advisory* IUC best-practice checks (marked ``(advisory)``). Prints one
447
+ ``file:line CODE message`` per finding. Exits non-zero on any *fixable*
448
+ finding or error; advisory findings are informational unless ``--strict``.
449
+ Macro-library files (``<macros>`` root) are also checked, for cosmetic
450
+ (fixable) drift only — the selection governs tools; macro files get the
451
+ standard cosmetic checks. PATHS may be files or directories; other XML is
452
+ skipped.
453
+ """
454
+ codes = _resolve(rulesets=rulesets, select=select, ignore=ignore)
455
+ fixable = advisory = flagged = clean = skipped = errored = 0
456
+ for target in iter_targets(paths):
457
+ try:
458
+ original = target.read_bytes()
459
+ except OSError as error:
460
+ click.echo(f"error: cannot read {target}: {error}", err=True)
461
+ errored += 1
462
+ continue
463
+ # Each finding is a (violation, is_advisory) pair. Tool files run the
464
+ # full selected detect (fixable GTR + advisory); macro files run the
465
+ # cosmetic macro rules only (all fixable). Other XML is skipped.
466
+ if is_tool_root(original):
467
+ try:
468
+ tool_document = load_tool(original)
469
+ except ToolXmlSyntaxError as error:
470
+ click.echo(f"error: {target}: malformed XML: {error}", err=True)
471
+ errored += 1
472
+ continue
473
+ if tool_document.root.tag != "tool":
474
+ skipped += 1
475
+ continue
476
+ result = facade.detect(tool_document, codes=codes)
477
+ findings = [(v, result.is_advisory(v)) for v in result.violations]
478
+ elif is_macros_root(original):
479
+ try:
480
+ macro_document = load_macros(original)
481
+ except ToolXmlSyntaxError as error:
482
+ click.echo(f"error: {target}: malformed XML: {error}", err=True)
483
+ errored += 1
484
+ continue
485
+ if macro_document.root.tag != "macros":
486
+ skipped += 1
487
+ continue
488
+ # Sort to match the tool path (facade.detect returns line-sorted
489
+ # violations), so `check` output ordering is consistent across kinds.
490
+ macro_violations = sorted(
491
+ detect_macro_document(macro_document),
492
+ key=lambda v: (v.sourceline, v.code),
493
+ )
494
+ findings = [(v, False) for v in macro_violations]
495
+ else:
496
+ skipped += 1
497
+ continue
498
+ if not findings:
499
+ clean += 1
500
+ continue
501
+ flagged += 1
502
+ for violation, is_advisory in findings:
503
+ if is_advisory:
504
+ advisory += 1
505
+ else:
506
+ fixable += 1
507
+ if not quiet:
508
+ suffix = " (advisory)" if is_advisory else ""
509
+ click.echo(
510
+ f"{target}:{violation.sourceline} "
511
+ f"{display_code(violation.code)} {violation.message}{suffix}"
512
+ )
513
+ if not quiet:
514
+ click.echo(
515
+ _check_summary(
516
+ fixable=fixable,
517
+ advisory=advisory,
518
+ flagged=flagged,
519
+ clean=clean,
520
+ skipped=skipped,
521
+ errored=errored,
522
+ )
523
+ )
524
+ fail = bool(errored or fixable or (strict and advisory))
525
+ sys.exit(1 if fail else 0)
526
+
527
+
528
+ @main.command(name="find-references")
529
+ @click.argument("name")
530
+ @_PATH_ARGUMENT
531
+ @_QUIET_OPTION
532
+ def find_references_command(
533
+ name: str, paths: tuple[Path, ...], quiet: bool
534
+ ) -> None:
535
+ """Report every Cheetah $NAME reference across a tool **and its imported macros**.
536
+
537
+ Read-only query (mutates nothing). For each tool it scans the tool's own
538
+ ``<command>``, inline ``<configfile>``\\ s, env vars, output labels and dynamic
539
+ options **plus every macro file it imports** (where a reference frequently lives),
540
+ and prints one ``file:line [section] $ref`` per occurrence whose identifier path
541
+ includes NAME (so ``$NAME``, ``$cond.NAME`` and ``$NAME.ext`` all match). PATHS may
542
+ be files or directories; non-tool XML is skipped. Occurrences are de-duplicated, so
543
+ a macro shared by several scanned tools is reported once. Conservative — may include
544
+ occurrences in comments/``#raw`` (see ``galaxy_tool_source.cheetah_refs``). Non-zero
545
+ exit on errors.
546
+ """
547
+ total = scanned = skipped = errored = 0
548
+ seen: set[tuple[str, int, str, str]] = set()
549
+ for target in iter_targets(paths):
550
+ try:
551
+ original = target.read_bytes()
552
+ except OSError as error:
553
+ click.echo(f"error: cannot read {target}: {error}", err=True)
554
+ errored += 1
555
+ continue
556
+ if not is_tool_root(original):
557
+ skipped += 1
558
+ continue
559
+ try:
560
+ result = find_references_in_bundle(target, name=name)
561
+ except ToolXmlSyntaxError as error:
562
+ click.echo(f"error: {target}: malformed XML: {error}", err=True)
563
+ errored += 1
564
+ continue
565
+ scanned += 1
566
+ for ref in result.references:
567
+ key = (str(ref.path), ref.sourceline, ref.section, ref.reference)
568
+ if key in seen:
569
+ continue
570
+ seen.add(key)
571
+ total += 1
572
+ if not quiet:
573
+ click.echo(
574
+ f"{ref.path}:{ref.sourceline} [{ref.section}] {ref.reference}"
575
+ )
576
+ if not quiet:
577
+ click.echo(f"{total} reference(s) to '{name}' across {scanned} tool(s)")
578
+ sys.exit(1 if errored else 0)
579
+
580
+
581
+ def _report_rename_skip(
582
+ result: BundleRenameResult, target: Path, *, quiet: bool
583
+ ) -> None:
584
+ """Print an informative skip line for a non-applied rename.
585
+
586
+ ``not-found`` is the common case (the tool has no such param) — it stays silent.
587
+ """
588
+ if result.reason == "not-found" or quiet:
589
+ return
590
+ if result.reason == "macro-edit-needs-repo-root":
591
+ click.echo(
592
+ f"skip {target}: '{result.old}' is referenced in an imported macro; "
593
+ "rerun with --repo-root DIR to prove the macro is sole-owned"
594
+ )
595
+ return
596
+ if result.reason == "macro-ownership-unprovable":
597
+ names = ", ".join(str(macro) for macro in result.unprovable)
598
+ click.echo(
599
+ f"skip {target}: cannot prove macro file(s) {names} are sole-owned within "
600
+ f"--repo-root (is {target} under the given --repo-root?)"
601
+ )
602
+ return
603
+ if result.reason == "shared-macro":
604
+ names = ", ".join(str(skip.macro_file) for skip in result.shared)
605
+ click.echo(
606
+ f"skip {target}: '{result.old}' is referenced in shared macro file(s) "
607
+ f"{names}; editing them would affect other tools (rename not applied)"
608
+ )
609
+ for skip in result.shared:
610
+ others = ", ".join(str(path) for path in skip.other_importers)
611
+ click.echo(f" {skip.macro_file} also imported by: {others}")
612
+ return
613
+ click.echo(f"skip {target}: {result.reason}")
614
+
615
+
616
+ def _report_consensus_skip(
617
+ result: ConsensusRenameResult, target: Path, *, quiet: bool
618
+ ) -> None:
619
+ """Print an informative skip line for a non-applied consensus rename."""
620
+ if result.reason == "not-found" or quiet:
621
+ return
622
+ if result.reason == "no-consensus":
623
+ click.echo(
624
+ f"skip {target}: cannot rename '{result.old}' across importers — "
625
+ "these tools cannot rename it safely:"
626
+ )
627
+ for tool, reason in result.dissenting:
628
+ click.echo(f" {tool}: {reason}")
629
+ return
630
+ if result.reason == "macro-ownership-unprovable":
631
+ click.echo(
632
+ f"skip {target}: a shared macro is not covered by --repo-root; "
633
+ "point --repo-root at the repository that holds every importer"
634
+ )
635
+ return
636
+ click.echo(f"skip {target}: {result.reason}")
637
+
638
+
639
+ def _run_consensus_rename(
640
+ paths: tuple[Path, ...],
641
+ *,
642
+ old: str,
643
+ new: str,
644
+ importers: Mapping[Path, frozenset[Path]],
645
+ check: bool,
646
+ backup: bool,
647
+ quiet: bool,
648
+ ) -> tuple[int, int, int, int]:
649
+ """Run the lockstep across-importers rename.
650
+
651
+ Returns the ``(renamed, would_change, skipped, errored)`` counts.
652
+ """
653
+ processed: set[Path] = set()
654
+ renamed = would_change = skipped = errored = 0
655
+ for target in iter_targets(paths):
656
+ try:
657
+ original = target.read_bytes()
658
+ except OSError as error:
659
+ click.echo(f"error: cannot read {target}: {error}", err=True)
660
+ errored += 1
661
+ continue
662
+ if not is_tool_root(original):
663
+ skipped += 1
664
+ continue
665
+ if target.resolve() in processed:
666
+ continue # already rewritten as part of an earlier consensus group
667
+ try:
668
+ result = rename_param_consensus(
669
+ target, old=old, new=new, importers=importers,
670
+ write=not check, backup=backup,
671
+ )
672
+ except ToolXmlSyntaxError as error:
673
+ click.echo(f"error: {target}: malformed XML: {error}", err=True)
674
+ errored += 1
675
+ continue
676
+ processed.add(target.resolve())
677
+ processed.update(result.tools)
678
+ if not result.changed:
679
+ _report_consensus_skip(result, target, quiet=quiet)
680
+ skipped += 1
681
+ continue
682
+ sites = sum(edit.renamed for edit in result.edits)
683
+ summary = (
684
+ f"{len(result.tools)} tool(s), {len(result.edits)} file(s), {sites} site(s)"
685
+ )
686
+ if check:
687
+ would_change += 1
688
+ if not quiet:
689
+ click.echo(f"would rename across importers from {target}: {summary}")
690
+ else:
691
+ renamed += 1
692
+ if not quiet:
693
+ click.echo(f"renamed across importers from {target}: {summary}")
694
+ return renamed, would_change, skipped, errored
695
+
696
+
697
+ @main.command(name="rename-param")
698
+ @click.argument("old")
699
+ @click.argument("new")
700
+ @_PATH_ARGUMENT
701
+ @_REPO_ROOT_OPTION
702
+ @_ACROSS_IMPORTERS_OPTION
703
+ @_CHECK_OPTION
704
+ @_BACKUP_OPTION
705
+ @_QUIET_OPTION
706
+ def rename_param_command(
707
+ old: str,
708
+ new: str,
709
+ paths: tuple[Path, ...],
710
+ repo_root: Path | None,
711
+ across_importers: bool,
712
+ check: bool,
713
+ backup: bool,
714
+ quiet: bool,
715
+ ) -> None:
716
+ """Rename parameter OLD to NEW across a tool **and its imported macro files**.
717
+
718
+ The mutating sibling of ``find-references``. Rewrites every live ``$OLD`` reference
719
+ (``<command>`` / inline ``<configfile>`` via the faithful lexer, attribute-Cheetah,
720
+ by-name cross-reference attributes, and the ``<tests>`` mirrors) plus the
721
+ definition — across the tool **and every macro file it imports**, so a reference
722
+ that lives only in an imported macro is no longer left dangling.
723
+
724
+ Rename is **atomic across the bundle**: every member is rewritten or none is. A
725
+ tool is skipped with a reason when the rename cannot be proven safe (e.g. a ``#set``
726
+ local shadows OLD, a section is mixed-content, or an output ``<filter>`` references
727
+ OLD by bare Python name). Editing an imported macro
728
+ requires ``--repo-root`` to prove the macro is **sole-owned** (imported by no other
729
+ tool); a macro **shared** with another tool is reported and the rename is skipped —
730
+ unless ``--across-importers`` is given, which renames OLD across *every* importer of
731
+ the shared macro in lockstep (only when they all agree). PATHS may be files or
732
+ directories; non-tool XML is skipped. ``--check`` previews without writing and exits
733
+ non-zero if any file would change.
734
+ """
735
+ if not old.isidentifier() or not new.isidentifier():
736
+ raise click.BadParameter("OLD and NEW must be valid identifiers")
737
+ if across_importers:
738
+ if repo_root is None:
739
+ raise click.BadParameter(
740
+ "--across-importers requires --repo-root to find every importer",
741
+ param_hint="--across-importers",
742
+ )
743
+ renamed, would_change, skipped, errored = _run_consensus_rename(
744
+ paths, old=old, new=new, importers=build_importer_map(repo_root),
745
+ check=check, backup=backup, quiet=quiet,
746
+ )
747
+ if not quiet:
748
+ done = would_change if check else renamed
749
+ verb = "would rename" if check else "renamed"
750
+ click.echo(f"{verb} {done} consensus group(s); skipped {skipped}")
751
+ sys.exit(1 if errored or (check and would_change) else 0)
752
+ importers = build_importer_map(repo_root) if repo_root is not None else None
753
+ renamed = would_change = skipped = errored = 0
754
+ for target in iter_targets(paths):
755
+ try:
756
+ original = target.read_bytes()
757
+ except OSError as error:
758
+ click.echo(f"error: cannot read {target}: {error}", err=True)
759
+ errored += 1
760
+ continue
761
+ if not is_tool_root(original):
762
+ skipped += 1
763
+ continue
764
+ try:
765
+ result = rename_param_bundle(
766
+ target,
767
+ old=old,
768
+ new=new,
769
+ importers=importers,
770
+ write=not check,
771
+ backup=backup,
772
+ )
773
+ except ToolXmlSyntaxError as error:
774
+ click.echo(f"error: {target}: malformed XML: {error}", err=True)
775
+ errored += 1
776
+ continue
777
+ if not result.changed:
778
+ _report_rename_skip(result, target, quiet=quiet)
779
+ skipped += 1
780
+ continue
781
+ sites = sum(edit.renamed for edit in result.edits)
782
+ files = len(result.edits)
783
+ if check:
784
+ would_change += 1
785
+ if not quiet:
786
+ click.echo(
787
+ f"would rename {target}: {sites} site(s) across {files} file(s)"
788
+ )
789
+ continue
790
+ renamed += 1
791
+ if not quiet:
792
+ click.echo(f"renamed {target}: {sites} site(s) across {files} file(s)")
793
+ if not quiet:
794
+ done = would_change if check else renamed
795
+ verb = "would rename" if check else "renamed"
796
+ click.echo(f"{verb} {done} tool(s); skipped {skipped}")
797
+ sys.exit(1 if errored or (check and would_change) else 0)
798
+
799
+
800
+ @main.command(name="rulesets")
801
+ def rulesets_command() -> None:
802
+ """List the available rulesets and the rule codes each one selects."""
803
+ for info in facade.list_rulesets():
804
+ default = " (default)" if info.is_default else ""
805
+ click.echo(f"{info.name}{default}: {info.description}")
806
+ click.echo(f" rules: {', '.join(info.codes)}")
807
+
808
+
809
+ @main.command(name="rules")
810
+ @click.option(
811
+ "--include-upgrade",
812
+ is_flag=True,
813
+ help=(
814
+ "Also list the non-selectable codemods: the upgrade-pipeline steps and "
815
+ "the opt-in-command-only rules (e.g. GTR092, applied by convert-help)."
816
+ ),
817
+ )
818
+ def rules_command(include_upgrade: bool) -> None:
819
+ """List the baked-in rules: code, family, fixable/advisory, rulesets, planemo.
820
+
821
+ The ``planemo:`` field lists the planemo (``galaxy.tool_util.lint``) linter(s)
822
+ each rule covers — those names also work in ``--select`` / ``--ignore``.
823
+ """
824
+ for info in facade.list_rules(include_upgrade=include_upgrade):
825
+ kind = "fixable" if info.fixable else "advisory"
826
+ in_rulesets = ",".join(info.rulesets) if info.rulesets else "-"
827
+ planemo = ",".join(info.planemo_linters) if info.planemo_linters else "-"
828
+ click.echo(
829
+ f"{info.code} [{info.family}/{kind}] rulesets:{in_rulesets} "
830
+ f"planemo:{planemo} {info.summary}"
831
+ )
832
+
833
+
834
+ def _collect_macro_files(paths: tuple[Path, ...], /) -> list[Path]:
835
+ """Resolve *paths* (files and/or directories) to ``<macros>``-root files.
836
+
837
+ A directory is searched recursively for ``*.xml`` whose root opens ``<macros``;
838
+ a file is included only when it is itself a macro-library file. De-duplicated by
839
+ resolved path and returned in a stable (sorted) order for deterministic output.
840
+ """
841
+ found: list[Path] = []
842
+ seen: set[Path] = set()
843
+ for path in paths:
844
+ candidates = sorted(path.rglob("*.xml")) if path.is_dir() else [path]
845
+ for candidate in candidates:
846
+ resolved = candidate.resolve()
847
+ if resolved in seen or not candidate.is_file():
848
+ continue
849
+ seen.add(resolved)
850
+ if is_macros_root(candidate.read_bytes()):
851
+ found.append(candidate)
852
+ return found
853
+
854
+
855
+ @main.command(name="normalize-macros")
856
+ @click.argument(
857
+ "paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
858
+ )
859
+ @click.option(
860
+ "--check", is_flag=True, help="Report what would change and write nothing."
861
+ )
862
+ @_BACKUP_OPTION
863
+ def normalize_macros_command(
864
+ paths: tuple[Path, ...], check: bool, backup: bool
865
+ ) -> None:
866
+ """Normalize literal format/ftype in macro-library files (opt-in, repo-scoped).
867
+
868
+ Lowercases literal ``format`` / ``ftype`` datatype tokens (leaving ``@TOKEN@``
869
+ placeholders alone) in every ``<macros>``-root file found under PATHS — the
870
+ macro-library analog of the 24.2 normalization the per-tool ``upgrade`` cannot
871
+ reach (a value defined in an imported macro file). Unlike ``format`` / ``upgrade``
872
+ this rewrites files other than the one named — a shared macro file affects every
873
+ importer — so it is a deliberate, separate command, never part of ``format``.
874
+ """
875
+ result = normalize_macro_files(
876
+ _collect_macro_files(paths), write=not check, backup=backup
877
+ )
878
+ verb = "would normalize" if check else "normalized"
879
+ for edit in result.edits:
880
+ click.echo(f"{verb} {edit.macro_file} ({edit.elements_changed} element(s))")
881
+ for bad in result.unparseable:
882
+ click.echo(f"skipped (could not parse): {bad}", err=True)
883
+ if not result.edits:
884
+ click.echo("no macro-library files needed normalization")
885
+
886
+
887
+ @main.command(name="convert-help")
888
+ @click.argument(
889
+ "paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
890
+ )
891
+ @click.option(
892
+ "--check", is_flag=True, help="Report what would convert and write nothing."
893
+ )
894
+ @_BACKUP_OPTION
895
+ def convert_help_command(paths: tuple[Path, ...], check: bool, backup: bool) -> None:
896
+ """Convert RST <help> bodies to Markdown (opt-in, render-equivalence gated).
897
+
898
+ Rewrites a tool's reStructuredText ``<help>`` as Markdown and marks it
899
+ ``format="markdown"`` — only when the conversion is *provable*: the tool's
900
+ profile must be >= 24.2 (``<help format=…>`` is not XSD-valid earlier — run
901
+ ``upgrade`` first), and the markdown-it rendering must be semantically equal
902
+ to the docutils rendering (invalid RST is first passed through the GTR089.1
903
+ repair). Anything unprovable is skipped with the reason. This conversion
904
+ swaps Galaxy's rendering engine (server-side docutils -> client-side
905
+ markdown-it), which is why it is a deliberate, separate command — never part
906
+ of ``format``/``upgrade``. Needs the ``galaxy-tool-source[markdown]`` extra.
907
+ """
908
+ converted = skipped = errored = 0
909
+ for target in iter_targets(paths):
910
+ try:
911
+ original = target.read_bytes()
912
+ except OSError as error:
913
+ click.echo(f"error: cannot read {target}: {error}", err=True)
914
+ errored += 1
915
+ continue
916
+ if not is_tool_root(original):
917
+ continue
918
+ try:
919
+ document = load_tool(original)
920
+ except ToolXmlSyntaxError as error:
921
+ click.echo(f"error: {target}: malformed XML: {error}", err=True)
922
+ errored += 1
923
+ continue
924
+ result = facade.convert_help(document)
925
+ if result.converted:
926
+ converted += 1
927
+ if not check:
928
+ if backup:
929
+ make_backup(target)
930
+ target.write_bytes(result.formatted)
931
+ click.echo(f"{'would convert' if check else 'converted'} {target}")
932
+ else:
933
+ skipped += 1
934
+ click.echo(f"skipped {target}: {result.skip_reason}")
935
+ click.echo(
936
+ f"{converted} converted, {skipped} skipped"
937
+ + (f", {errored} error(s)" if errored else "")
938
+ )
939
+ if errored:
940
+ raise SystemExit(1)
941
+
942
+
943
+ @main.command(name="tokenize-version")
944
+ @click.argument(
945
+ "paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
946
+ )
947
+ @click.option(
948
+ "--check", is_flag=True, help="Report what would tokenize and write nothing."
949
+ )
950
+ @click.option(
951
+ "--macros-file",
952
+ default=None,
953
+ metavar="NAME",
954
+ help=(
955
+ "Put the two tokens in a separate macros file NAME (e.g. macros.xml) the "
956
+ "tool imports, instead of an inline <macros> block (the default). NAME is "
957
+ "created when absent, or the tokens are merged into an existing NAME when "
958
+ "proven not to change any other importer; tools in a directory that share "
959
+ "NAME at the same version are tokenized together."
960
+ ),
961
+ )
962
+ @click.option(
963
+ "--adopt-suffix",
964
+ is_flag=True,
965
+ help=(
966
+ "IDENTITY-CHANGING: for a tool whose bare version equals a package "
967
+ "<requirement> but has no +galaxy suffix, ADD +galaxy0 and tokenize. This "
968
+ "changes the published version (1.20 -> 1.20+galaxy0); use only when you are "
969
+ "intentionally adopting the convention. Inline only; not combinable with "
970
+ "--macros-file."
971
+ ),
972
+ )
973
+ @_BACKUP_OPTION
974
+ def tokenize_version_command(
975
+ paths: tuple[Path, ...],
976
+ check: bool,
977
+ macros_file: str | None,
978
+ adopt_suffix: bool,
979
+ backup: bool,
980
+ ) -> None:
981
+ """Factor a literal version into @TOOL_VERSION@/@VERSION_SUFFIX@ (opt-in, gated).
982
+
983
+ Rewrites ``version="<base>+galaxy<suffix>"`` as
984
+ ``@TOOL_VERSION@+galaxy@VERSION_SUFFIX@``, retargets the matching package
985
+ ``<requirement>`` versions to ``@TOOL_VERSION@``, and defines the two
986
+ tokens in the tool's inline ``<macros>`` (or, with ``--macros-file``, in a
987
+ separate macros file the tool imports), only when *provable*: the
988
+ expansion-equality gate keeps the change solely when macro-expanding the
989
+ tokenized tool reproduces the original expansion byte-for-byte. Anything
990
+ unprovable is skipped with the reason. A multi-element style restructure,
991
+ which is why it is a deliberate, separate command, never part of
992
+ ``format``/``upgrade``. Files are passed by path so imported macros resolve.
993
+
994
+ ``--adopt-suffix`` is the **identity-changing** sibling: for a tool whose *bare*
995
+ version equals a package requirement, it adds ``+galaxy0`` (so ``1.20`` becomes
996
+ ``1.20+galaxy0``) and tokenizes. The published version changes, so it is opt-in and
997
+ gated only on the controlled-change gate (the expansion changes solely in the
998
+ version attribute).
999
+ """
1000
+ if adopt_suffix and macros_file is not None:
1001
+ click.echo(
1002
+ "error: --adopt-suffix cannot be combined with --macros-file", err=True
1003
+ )
1004
+ raise SystemExit(1)
1005
+ if adopt_suffix:
1006
+ _run_adopt_suffix(paths, check=check, backup=backup)
1007
+ return
1008
+ if macros_file is not None:
1009
+ _run_tokenize_shared(paths, macros_file=macros_file, check=check, backup=backup)
1010
+ return
1011
+ tokenized = skipped = errored = 0
1012
+ for target in iter_targets(paths):
1013
+ try:
1014
+ original = target.read_bytes()
1015
+ except OSError as error:
1016
+ click.echo(f"error: cannot read {target}: {error}", err=True)
1017
+ errored += 1
1018
+ continue
1019
+ if not is_tool_root(original):
1020
+ continue
1021
+ # Pass the PATH (not bytes): the expansion gate resolves <import>ed
1022
+ # macro files against the tool's own directory.
1023
+ result = facade.tokenize_version(target)
1024
+ if result.tokenized:
1025
+ tokenized += 1
1026
+ if not check:
1027
+ if backup:
1028
+ make_backup(target)
1029
+ target.write_bytes(result.formatted)
1030
+ verb = "would tokenize" if check else "tokenized"
1031
+ click.echo(f"{verb} {target}")
1032
+ else:
1033
+ skipped += 1
1034
+ click.echo(f"skipped {target}: {result.skip_reason}")
1035
+ click.echo(
1036
+ f"{tokenized} tokenized, {skipped} skipped"
1037
+ + (f", {errored} error(s)" if errored else "")
1038
+ )
1039
+ if errored:
1040
+ raise SystemExit(1)
1041
+
1042
+
1043
+ def _run_adopt_suffix(
1044
+ paths: tuple[Path, ...], *, check: bool, backup: bool
1045
+ ) -> None:
1046
+ """``tokenize-version --adopt-suffix``: add +galaxy0 to a bare version, tokenize.
1047
+
1048
+ Identity-changing (the published version changes), so each applied tool is
1049
+ reported loudly. Gated per tool by the controlled-change gate.
1050
+ """
1051
+ adopted = skipped = errored = 0
1052
+ for target in iter_targets(paths):
1053
+ try:
1054
+ original = target.read_bytes()
1055
+ except OSError as error:
1056
+ click.echo(f"error: cannot read {target}: {error}", err=True)
1057
+ errored += 1
1058
+ continue
1059
+ if not is_tool_root(original):
1060
+ continue
1061
+ result = facade.adopt_version_suffix(target)
1062
+ if result.tokenized:
1063
+ adopted += 1
1064
+ if not check:
1065
+ if backup:
1066
+ make_backup(target)
1067
+ target.write_bytes(result.formatted)
1068
+ verb = "would adopt" if check else "adopted"
1069
+ click.echo(f"{verb} +galaxy0 in {target} (published version changed)")
1070
+ else:
1071
+ skipped += 1
1072
+ click.echo(f"skipped {target}: {result.skip_reason}")
1073
+ click.echo(
1074
+ f"{adopted} adopted, {skipped} skipped"
1075
+ + (f", {errored} error(s)" if errored else "")
1076
+ )
1077
+ if errored:
1078
+ raise SystemExit(1)
1079
+
1080
+
1081
+ def _run_tokenize_shared(
1082
+ paths: tuple[Path, ...], *, macros_file: str, check: bool, backup: bool
1083
+ ) -> None:
1084
+ """``tokenize-version --macros-file``: group tools by directory, tokenize each set.
1085
+
1086
+ Each directory's target tools that share ``macros_file`` at the same version are
1087
+ tokenized together (consensus), defining the shared tokens once. See
1088
+ ``galaxy_tool_refactor_registry.version_token_share``.
1089
+ """
1090
+ if "/" in macros_file or "\\" in macros_file or macros_file in {"", ".", ".."}:
1091
+ click.echo(
1092
+ f"error: --macros-file must be a plain filename, not {macros_file!r}",
1093
+ err=True,
1094
+ )
1095
+ raise SystemExit(1)
1096
+ groups: dict[Path, list[Path]] = defaultdict(list)
1097
+ errored = 0
1098
+ for target in iter_targets(paths):
1099
+ try:
1100
+ raw = target.read_bytes()
1101
+ except OSError as error:
1102
+ click.echo(f"error: cannot read {target}: {error}", err=True)
1103
+ errored += 1
1104
+ continue
1105
+ if is_tool_root(raw):
1106
+ groups[target.parent].append(target)
1107
+ tokenized = skipped = 0
1108
+ verb = "would tokenize" if check else "tokenized"
1109
+ for directory, tools in sorted(groups.items()):
1110
+ plan = facade.tokenize_version_shared(
1111
+ directory / macros_file, target_tools=tools
1112
+ )
1113
+ for tool_path, reason in plan.skipped:
1114
+ click.echo(f"skipped {tool_path}: {reason}")
1115
+ skipped += 1
1116
+ if not plan.tool_edits:
1117
+ unreported = len(tools) - len(plan.skipped)
1118
+ if plan.skip_reason is not None and unreported > 0:
1119
+ click.echo(f"skipped {directory} ({macros_file}): {plan.skip_reason}")
1120
+ skipped += unreported
1121
+ continue
1122
+ if not check:
1123
+ if plan.macros_content is not None:
1124
+ if not plan.macros_created and backup:
1125
+ make_backup(plan.macros_path)
1126
+ plan.macros_path.write_bytes(plan.macros_content)
1127
+ for edit in plan.tool_edits:
1128
+ if backup:
1129
+ make_backup(edit.path)
1130
+ edit.path.write_bytes(edit.content)
1131
+ file_note = f"{'created' if plan.macros_created else 'updated'} {macros_file}"
1132
+ for edit in plan.tool_edits:
1133
+ click.echo(f"{verb} {edit.path} (-> {file_note})")
1134
+ tokenized += len(plan.tool_edits)
1135
+ click.echo(
1136
+ f"{tokenized} tokenized, {skipped} skipped"
1137
+ + (f", {errored} error(s)" if errored else "")
1138
+ )
1139
+ if errored:
1140
+ raise SystemExit(1)
1141
+
1142
+
1143
+ if __name__ == "__main__":
1144
+ main()
File without changes
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: galaxy-tool-refactor-cli
3
+ Version: 0.2.0
4
+ Summary: Top-level CLI app that composes the Galaxy tool refactoring tiers (format + upgrade).
5
+ Author: Richard Burhans
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8
9
+ Requires-Dist: galaxy-tool-fmt==0.2.0
10
+ Requires-Dist: galaxy-tool-refactor-registry==0.2.0
11
+ Requires-Dist: galaxy-tool-source==0.2.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # galaxy-tool-refactor-cli
15
+
16
+ The **app tier** of the Galaxy tool refactoring framework — the user-facing
17
+ `galaxy-tool-refactor` CLI, a thin front-end over the tier-3.6 rule-registry
18
+ facade (`galaxy-tool-refactor-registry`).
19
+
20
+ | Tier | Layer | Package |
21
+ |---|---|---|
22
+ | 0.5 | rule metadata | `galaxy-tool-refactor-rules` |
23
+ | 1 | parsing & validation | `galaxy-tool-source` |
24
+ | 2 | structure | `galaxy-tool-codemod` |
25
+ | 3 | formatting | `galaxy-tool-fmt` |
26
+ | 3.5 | advisory checks | `galaxy-tool-lint` |
27
+ | 3.6 | rule registry / rulesets | `galaxy-tool-refactor-registry` |
28
+ | 4 | **app / CLI** | `galaxy-tool-refactor-cli` *(this package)* |
29
+
30
+ Rule orchestration lives in the registry facade; this package depends on it
31
+ (plus fmt's `cli_support` engine and tier-1 parsing) and exposes ten commands
32
+ (`format`, `upgrade`, `check`, `find-references`, `rename-param`, `rulesets`, `rules`,
33
+ `normalize-macros`):
34
+
35
+ ```bash
36
+ # Safe, idempotent: apply a ruleset's fixable rules + cosmetic formatting.
37
+ # Default ruleset = structural canonicalisation + cosmetic; never profile=.
38
+ galaxy-tool-refactor format tool.xml
39
+ galaxy-tool-refactor format --ruleset cosmetic tool.xml # whitespace only
40
+ galaxy-tool-refactor format --ignore GTR002 tool.xml # all but param-reorder
41
+ galaxy-tool-refactor format tools/ # also formats <macros> files
42
+
43
+ # Opt-in, semantic: repair typos, then upgrade profile= to the latest reachable
44
+ # version (applying each step's structural migration), then format. Reports the
45
+ # steps applied and warns if a tool stalls. No --ruleset; --select/--ignore tune it.
46
+ galaxy-tool-refactor upgrade tool.xml
47
+
48
+ # Report-only linter: one `file:line CODE message` per finding, mutating
49
+ # nothing. The default ruleset reports the fixable GTR rules; `--ruleset strict` adds
50
+ # the advisory checks (marked `(advisory)`). Exits non-zero on any fixable
51
+ # finding; advisory findings are informational unless --strict.
52
+ galaxy-tool-refactor check tool.xml
53
+ galaxy-tool-refactor check --ruleset strict tool.xml
54
+
55
+ # Introspection.
56
+ galaxy-tool-refactor rulesets
57
+ galaxy-tool-refactor rules
58
+
59
+ # Opt-in, repo-scoped: lowercase literal format/ftype in <macros>-root files (the
60
+ # macro-library fix the per-tool `upgrade` can't reach). Rewrites files other than
61
+ # the one named, so it is a separate command — never part of format/upgrade.
62
+ galaxy-tool-refactor normalize-macros macros/ # --check to preview
63
+ ```
64
+
65
+ `format`/`upgrade`/`check` share rule selection — `--ruleset NAME`
66
+ (repeatable / comma-separated — the union of the named sets),
67
+ `--select CODE…`, `--ignore CODE…` (ruff-style precedence: `--ignore` ▸
68
+ `--select` ▸ `--ruleset`; `--select` replaces the rulesets' set; `upgrade` takes no
69
+ `--ruleset`). `format`/`upgrade` also honour `--check` (detect drift, exit
70
+ non-zero, don't write — distinct from the `check` *command*), `--diff`, and
71
+ `--quiet`; `check` honours `--quiet` and `--strict`. The typical modernization
72
+ flow is `upgrade` then `format`.
73
+
74
+ ## Why a separate tier
75
+
76
+ Profile upgrade is semantic, fallible, and reports outcomes; canonicalisation +
77
+ formatting is safe and idempotent. Keeping them in separate, explicit commands
78
+ (rather than auto-upgrading inside "format my tool") lets users opt into
79
+ modernization deliberately. Rule orchestration sits *below* the CLI in the
80
+ registry facade — both because output is written via fmt's serializer (so the
81
+ orchestrator must sit above fmt) and so the MCP server reuses the same
82
+ core. See `docs/decisions.md` §D1 (the app tier), §D2 (`check`), §D3 (advisory
83
+ findings), §D4 (the registry facade + rule selection).
84
+
85
+ ## Install / test
86
+
87
+ ```bash
88
+ uv sync # from the workspace root
89
+ uv run --package galaxy-tool-refactor-cli pytest galaxy-tool-refactor-cli/tests/
90
+ ```
@@ -0,0 +1,7 @@
1
+ galaxy_tool_refactor_cli/__init__.py,sha256=qxO1BbNJw4OJFxKNkst-q12J9QJe2-Jjfh4aNgluA2A,1253
2
+ galaxy_tool_refactor_cli/cli.py,sha256=Wv0XknNk4SjihrR-SOG5AR9n2CwUPGnMYPaCMIK2sik,45659
3
+ galaxy_tool_refactor_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ galaxy_tool_refactor_cli-0.2.0.dist-info/METADATA,sha256=sQTh6yDTtEeSGj9Avft-05QJs9ukTU5QDgAymeuZeJs,4182
5
+ galaxy_tool_refactor_cli-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ galaxy_tool_refactor_cli-0.2.0.dist-info/entry_points.txt,sha256=X003rXqXxy03bXBufCx2HIn655ONx5wUTqXf_Rqit2o,75
7
+ galaxy_tool_refactor_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ galaxy-tool-refactor = galaxy_tool_refactor_cli.cli:main