ub-test-reports 1.0.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,17 @@
1
+ """ub-test-reports: test results as needs, without Sphinx.
2
+
3
+ The report parsers, the result vocabulary, the deterministic case IDs, the
4
+ ``[test_reports]`` model of ``ubproject.toml``, the needs.json converter behind the
5
+ ``test-reports`` command, and the pytest plugin (``-p ub_test_reports.pytest_plugin``).
6
+ The modules are the API; this file only carries the version.
7
+
8
+ **Nothing in this package may import Sphinx, sphinx-needs or docutils** -- not at module
9
+ level and not in a function body. sphinx-test-reports, the Sphinx extension, depends on
10
+ this package, never the other way round.
11
+ """
12
+
13
+ __all__ = ["__version__"]
14
+
15
+ #: Checked against ``[project] version`` by ``check_workspace.py`` and stamped by
16
+ #: ``poe bump``.
17
+ __version__ = "1.0.0"
ub_test_reports/cli.py ADDED
@@ -0,0 +1,580 @@
1
+ """``test-reports`` command line interface.
2
+
3
+ ``test-reports build needs`` turns test-result XML into needs.json *outside* Sphinx, so a build system can
4
+ schedule and cache the conversion and the documentation build only imports the
5
+ result. Nothing in the import chain of this module may import Sphinx; the test
6
+ suite asserts that.
7
+
8
+ The conversion settings come from three places, highest precedence first: a
9
+ command-line flag, the ``[test_reports.build.needs]`` table of the project's
10
+ ``ubproject.toml``, the built-in default. The file is the declarative
11
+ description of the project that the Sphinx build reads too, so the two
12
+ consumers cannot drift apart; the flags stay for per-invocation values such as
13
+ the commit a CI job is converting for.
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from collections.abc import Mapping, Sequence
20
+ from pathlib import Path
21
+
22
+ from ub_test_reports.junitparser import JUnitParser
23
+ from ub_test_reports.needs_export import (
24
+ DEFAULT_VERSION,
25
+ Report,
26
+ build_needs_file,
27
+ iter_cases,
28
+ optional,
29
+ )
30
+ from ub_test_reports.projectconfig import (
31
+ CONVERSION_KEYS,
32
+ DEFAULT_NEED_TYPE,
33
+ DEFAULT_TOML_FILENAME,
34
+ NEEDS_TABLE_PATH,
35
+ SECTION,
36
+ TomlConfigError,
37
+ case_need_type,
38
+ field_names,
39
+ find_project_config,
40
+ load_project_config,
41
+ needs_settings,
42
+ )
43
+ from ub_test_reports.remote import (
44
+ DEFAULT_URL_PATTERN,
45
+ check_url_pattern,
46
+ normalise_remote_url,
47
+ )
48
+
49
+ #: How the table is spelled in help texts and diagnostics.
50
+ TABLE = f"[{NEEDS_TABLE_PATH}]"
51
+
52
+
53
+ def _warn(message: str) -> None:
54
+ print(f"warning: {message}", file=sys.stderr)
55
+
56
+
57
+ # The conversion settings are resolved key by key in a loop (see
58
+ # `_resolve_settings`), which a TypedDict cannot express, so the mapping stays
59
+ # `dict[str, object]`. These two state at the point of use the type
60
+ # `_NEEDS_KEY_TYPES` already enforces when the file is read.
61
+ def _text(value: object) -> str:
62
+ """A setting the validation pins to a string."""
63
+ return value if isinstance(value, str) else ""
64
+
65
+
66
+ def _texts(value: object) -> "list[str]":
67
+ """A setting the validation pins to a list of strings."""
68
+ return [str(item) for item in value] if isinstance(value, list) else []
69
+
70
+
71
+ def _build_parser() -> argparse.ArgumentParser:
72
+ parser = argparse.ArgumentParser(
73
+ prog="test-reports",
74
+ description="Convert test-result XML into sphinx-needs data.",
75
+ )
76
+ subcommands = parser.add_subparsers(dest="command", required=True)
77
+
78
+ # `build <artifact>`, as ubCode spells it (`ubc build needs`): the command
79
+ # names what gets produced, and leaves room for further artifacts.
80
+ build = subcommands.add_parser(
81
+ "build",
82
+ help="Build an artifact from test-result XML.",
83
+ description="Build an artifact from one or more test-result XML files.",
84
+ )
85
+ artifacts = build.add_subparsers(dest="artifact", required=True)
86
+ needs = artifacts.add_parser(
87
+ "needs",
88
+ help="Build a needs.json from one or more test-result XML files.",
89
+ description=(
90
+ "Build a needs.json from one or more test-result XML files, ready "
91
+ "to be consumed with needimport or needs_external_needs. Settings "
92
+ f"default to the {TABLE} table of {DEFAULT_TOML_FILENAME}; a flag "
93
+ "overrides the file's value for that key."
94
+ ),
95
+ )
96
+ needs.add_argument(
97
+ "files",
98
+ nargs="+",
99
+ metavar="FILE",
100
+ help="Test-result XML files (a build system passes these as a file list).",
101
+ )
102
+ needs.add_argument(
103
+ "--output",
104
+ "-o",
105
+ required=True,
106
+ metavar="PATH",
107
+ help="Where to write the needs.json.",
108
+ )
109
+ # Every setting the TOML file can supply defaults to None here and is
110
+ # resolved in _resolve_settings: a given flag beats the file, which beats
111
+ # the built-in default. None is safe because each key resolves to a
112
+ # concrete value there.
113
+ needs.add_argument(
114
+ "--project",
115
+ default=None,
116
+ help="Project name recorded in the needs.json envelope (default: empty).",
117
+ )
118
+ needs.add_argument(
119
+ "--version",
120
+ default=None,
121
+ help=f"Version key in the envelope (default: {DEFAULT_VERSION}).",
122
+ )
123
+ needs.add_argument(
124
+ "--need-type",
125
+ default=None,
126
+ help=(
127
+ f"Need type for each test case (default: {DEFAULT_NEED_TYPE}). Must "
128
+ f"agree with the type of case in [{SECTION}] when a config file "
129
+ "applies."
130
+ ),
131
+ )
132
+ needs.add_argument(
133
+ "--tags",
134
+ default=None,
135
+ help="Comma-separated tags applied to every created need.",
136
+ )
137
+ needs.add_argument(
138
+ "--link-property",
139
+ action="append",
140
+ default=None,
141
+ metavar="PROPERTY=LINK_FIELD",
142
+ help=(
143
+ "Promote an XML property to a link field, comma-splitting its value "
144
+ "(repeatable), e.g. PartiallyVerifies=partially_verifies. Given at "
145
+ "all, it replaces the file's link_properties table."
146
+ ),
147
+ )
148
+ needs.add_argument(
149
+ # Named after `tr_extra_options`, which the flag defaults from.
150
+ # sphinx-needs has since renamed `needs_extra_options` to
151
+ # `needs_fields`; realigning this extension's vocabulary is issue #157.
152
+ "--extra-option",
153
+ action="append",
154
+ default=None,
155
+ metavar="NAME",
156
+ help=(
157
+ "Export the XML property NAME as a need field (repeatable), in "
158
+ f"addition to the extra_options of [{SECTION}] -- the same list that "
159
+ "makes the build accept the field. A NAME that list lacks is "
160
+ "reported. Properties named by neither are reported and left out."
161
+ ),
162
+ )
163
+ needs.add_argument(
164
+ "--remote-url",
165
+ default=None,
166
+ help="Repository URL used to synthesize source links; git remotes are accepted.",
167
+ )
168
+ needs.add_argument(
169
+ "--commit",
170
+ default=None,
171
+ help="Commit-ish the reports were produced from.",
172
+ )
173
+ needs.add_argument(
174
+ "--url-pattern",
175
+ default=None,
176
+ help=f"Source-URL template (default: {DEFAULT_URL_PATTERN}).",
177
+ )
178
+ needs.add_argument(
179
+ "-v",
180
+ "--verbose",
181
+ action="store_true",
182
+ help=(
183
+ "Say on stderr where the search for the declarative config file "
184
+ "ended when it found none, and name a --config file. Off by "
185
+ "default: most runs have no file and should stay quiet, but a "
186
+ "misplaced one must be diagnosable. A file the search did find is "
187
+ "always named -- it may sit directories above, and the output "
188
+ "depends on it."
189
+ ),
190
+ )
191
+ config_source = needs.add_mutually_exclusive_group()
192
+ config_source.add_argument(
193
+ "--config",
194
+ default=None,
195
+ metavar="PATH",
196
+ help=(
197
+ f"Declarative config file to read the {TABLE} table from. By "
198
+ f"default {DEFAULT_TOML_FILENAME} is searched for in the working "
199
+ "directory and its parents, up to the project root; an explicitly "
200
+ "given path is used as-is and must exist."
201
+ ),
202
+ )
203
+ config_source.add_argument(
204
+ "--no-config",
205
+ action="store_true",
206
+ help=(
207
+ "Ignore the declarative config file entirely, so the output "
208
+ "depends only on the arguments given here."
209
+ ),
210
+ )
211
+ return parser
212
+
213
+
214
+ def _parse_link_properties(values: object) -> dict[str, str]:
215
+ """Normalise the link-property mapping from any input spelling.
216
+
217
+ Flags provide ``PROPERTY=LINK_FIELD`` strings; the TOML file provides a
218
+ ``PROPERTY = "LINK_FIELD"`` table; neither given is ``None``. Both spellings
219
+ must yield non-empty keys and values -- a silently dropped mapping would
220
+ send link fields into needs.json as plain fields instead. This is the only
221
+ place the mapping is normalised, so the two spellings cannot drift.
222
+
223
+ The parameter is ``object`` because the value arrives from a flag or from
224
+ the TOML file; this function is where its shape is established.
225
+ """
226
+ if values is None:
227
+ return {}
228
+ if isinstance(values, dict):
229
+ mapping = {}
230
+ for property_name, link_field in values.items():
231
+ if not str(property_name).strip() or not str(link_field).strip():
232
+ raise ValueError(
233
+ f'link_properties expects PROPERTY = "LINK_FIELD", got '
234
+ f"{property_name!r} = {link_field!r}"
235
+ )
236
+ mapping[str(property_name).strip()] = str(link_field).strip()
237
+ return mapping
238
+
239
+ if not isinstance(values, list):
240
+ raise ValueError(
241
+ f'link_properties expects a PROPERTY = "LINK_FIELD" table, got {values!r}'
242
+ )
243
+
244
+ mapping = {}
245
+ for value in values:
246
+ property_name, separator, link_field = str(value).partition("=")
247
+ if not separator or not property_name.strip() or not link_field.strip():
248
+ raise ValueError(
249
+ f"--link-property expects PROPERTY=LINK_FIELD, got {value!r}"
250
+ )
251
+ mapping[property_name.strip()] = link_field.strip()
252
+ return mapping
253
+
254
+
255
+ def _warn_about_absent_source_lines(
256
+ path: Path, suites: Sequence[Mapping[str, object]]
257
+ ) -> None:
258
+ """Report the most common cause of a missing source location.
259
+
260
+ pytest emits ``file``/``line`` as ``<testcase>`` attributes only under
261
+ ``junit_family = xunit1`` (or ``legacy``); its default ``xunit2`` filters
262
+ them out, which silently costs the source location of every case.
263
+ """
264
+ # Walk the report the way the export does: the parser files the cases of
265
+ # a suite with nested <testsuite> elements under testsuite_nested only, so
266
+ # looking at the top-level testcases alone goes quiet on nested reports.
267
+ cases = [case for _suite_name, case in iter_cases(suites)]
268
+ if cases and all(optional(case.get("line"), -1) == "" for case in cases):
269
+ print(
270
+ f"warning: {path}: no <testcase> carries a 'line' attribute, so no "
271
+ "source location could be recorded. pytest emits file/line only "
272
+ "with junit_family = xunit1 (or legacy); its default xunit2 drops "
273
+ "them.",
274
+ file=sys.stderr,
275
+ )
276
+
277
+
278
+ def _warn_about_empty_report(
279
+ path: Path, suites: Sequence[Mapping[str, object]]
280
+ ) -> None:
281
+ """A report without a single test case contributes nothing -- say so.
282
+
283
+ A file that is not a test report at all (a ``pom.xml``, a ``coverage.xml``)
284
+ parses as one empty suite, and a genuine report may have had every case
285
+ filtered out. Either way the artifact ends up with fewer needs than the
286
+ caller expects, and for a cached build output "0 needs" is the one outcome
287
+ that must never be silent.
288
+ """
289
+ if not any(True for _ in iter_cases(suites)):
290
+ _warn(
291
+ f"{path}: no test cases found, so it adds no needs. A file that is "
292
+ f"not a test report parses as an empty one."
293
+ )
294
+
295
+
296
+ def _load_section(
297
+ arguments: argparse.Namespace,
298
+ ) -> "tuple[dict[str, object], Path | None, str | None]":
299
+ """Load the ``[test_reports]`` section, honouring ``--config``/``--no-config``.
300
+
301
+ Returns ``(section, path, error_message)``. ``section`` is ``{}`` and
302
+ ``path`` ``None`` when no file applies: an absent *default* file is not an
303
+ error, an explicitly given path that cannot be read is.
304
+
305
+ The whole section is loaded, not only the converter's table: validation
306
+ covers the file as the build sees it, and the build's ``case`` entry is
307
+ needed to check the need type against.
308
+
309
+ The default file is searched for upwards from the working directory, since
310
+ it conventionally sits at the project root while the converter runs from
311
+ wherever CI invoked it. That is also where the Sphinx side looks, so both
312
+ consumers read the same file. A file the search finds is always named on
313
+ stderr: it may sit directories above the invocation, and the bytes written
314
+ depend on it, so a cached artifact has to be traceable to the file that
315
+ shaped it -- the Sphinx bridge logs the same at INFO on every build. With
316
+ ``--verbose`` a fruitless search also reports where it ended, and an
317
+ explicitly given file is named too.
318
+ """
319
+ if arguments.no_config:
320
+ return {}, None, None
321
+
322
+ def verbose(message: str) -> None:
323
+ if arguments.verbose:
324
+ print(message, file=sys.stderr)
325
+
326
+ if arguments.config is not None:
327
+ path = Path(arguments.config)
328
+ if not path.is_file():
329
+ return {}, None, f"error: no such config file: {path}"
330
+ verbose(f"reading [{SECTION}] from {path}")
331
+ else:
332
+ found = find_project_config(Path.cwd(), report=verbose)
333
+ if found is None:
334
+ return {}, None, None
335
+ path = found
336
+ print(f"reading [{SECTION}] from {path}", file=sys.stderr)
337
+
338
+ try:
339
+ section = load_project_config(path, _warn) or {}
340
+ except TomlConfigError as error:
341
+ return {}, path, f"error: {error}"
342
+ return section, path, None
343
+
344
+
345
+ #: Built-in value of every conversion setting, used when neither a flag nor
346
+ #: the TOML file supplies one. Keyed by :data:`CONVERSION_KEYS`, and checked
347
+ #: against it at import time, so a key cannot be added to the table without
348
+ #: also being given a default here.
349
+ _DEFAULTS: "dict[str, object]" = {
350
+ "project": "",
351
+ "version": DEFAULT_VERSION,
352
+ "need_type": DEFAULT_NEED_TYPE,
353
+ "tags": "",
354
+ "link_properties": None,
355
+ "remote_url": "",
356
+ "commit": "",
357
+ "url_pattern": DEFAULT_URL_PATTERN,
358
+ }
359
+ if set(_DEFAULTS) != set(CONVERSION_KEYS): # pragma: no cover - import-time guard
360
+ raise RuntimeError("every [test_reports.build.needs] key needs a built-in default")
361
+
362
+ #: argparse destination per conversion key, where it differs from the key.
363
+ #: Only the repeatable ``--link-property`` flag does.
364
+ _DESTS = {"link_properties": "link_property"}
365
+
366
+ #: Command-line spelling of a conversion key, for diagnostics.
367
+ _FLAGS = {key: "--" + key.replace("_", "-") for key in CONVERSION_KEYS}
368
+ _FLAGS["link_properties"] = "--link-property"
369
+
370
+
371
+ def _resolve_settings(
372
+ arguments: argparse.Namespace, table: "dict[str, object]"
373
+ ) -> "tuple[dict[str, object], dict[str, str]]":
374
+ """Merge the conversion settings: flag > TOML table > built-in default.
375
+
376
+ Flags default to ``None``, so ``None`` means "not given"; every key resolves
377
+ to a concrete value here. Also returns, per key, where the value came from,
378
+ because a diagnostic has to name the flag or the TOML key the user actually
379
+ wrote.
380
+ """
381
+ resolved: dict[str, object] = {}
382
+ sources: dict[str, str] = {}
383
+ for key in CONVERSION_KEYS:
384
+ flag = getattr(arguments, _DESTS.get(key, key))
385
+ if flag is not None:
386
+ resolved[key], sources[key] = flag, "flag"
387
+ elif key in table:
388
+ resolved[key], sources[key] = table[key], "toml"
389
+ else:
390
+ resolved[key], sources[key] = _DEFAULTS[key], "default"
391
+
392
+ tags = resolved["tags"]
393
+ if isinstance(tags, str): # comma-separated, from the flag
394
+ resolved["tags"] = [tag.strip() for tag in tags.split(",") if tag.strip()]
395
+ else: # array from the TOML file, or the key is absent
396
+ resolved["tags"] = _texts(tags)
397
+
398
+ return resolved, sources
399
+
400
+
401
+ def _spell(key: str, sources: "dict[str, str]") -> str:
402
+ """The key as the user wrote it: the flag, the TOML key, or the default."""
403
+ source = sources.get(key)
404
+ if source == "toml":
405
+ return key
406
+ if source == "default":
407
+ return f"the default {key}"
408
+ return _FLAGS[key]
409
+
410
+
411
+ def _pair_requirement(sources: "dict[str, str]", path: "Path | None") -> str:
412
+ """Name the remote-url/commit pair the way the user actually spelled it.
413
+
414
+ Either half can come from the TOML file, so naming only the flags would
415
+ point at options that appear nowhere in the invocation.
416
+ """
417
+ # The half that is missing is named by the flag that would supply it --
418
+ # that is the actionable spelling -- and the half that was given by however
419
+ # the user gave it.
420
+ names = [
421
+ key if sources.get(key) == "toml" else _FLAGS[key]
422
+ for key in ("remote_url", "commit")
423
+ ]
424
+ requirement = f"{names[0]} and {names[1]}"
425
+ if "toml" in (sources.get("remote_url"), sources.get("commit")):
426
+ requirement += f" (the bare names are {TABLE} keys in {path})"
427
+ return requirement
428
+
429
+
430
+ def _extra_options(
431
+ arguments: argparse.Namespace,
432
+ section: "dict[str, object]",
433
+ config_path: "Path | None",
434
+ ) -> "list[str]":
435
+ """Properties exported as fields: the section's ``extra_options`` plus the flag's.
436
+
437
+ The flag adds to the file's list rather than replacing it: a repeatable
438
+ flag reads as additive, and ``--no-config`` is the way to leave the file
439
+ out. A flag name the section's list does not contain is exported all the
440
+ same -- the flag may stand in for a build configured elsewhere -- but
441
+ reported: the build registers exactly the section's list, so an import of
442
+ the produced file drops every other field as an unknown key.
443
+ """
444
+ configured = _texts(section.get("extra_options", []))
445
+ if arguments.extra_option is None:
446
+ return configured
447
+ added = [str(name) for name in arguments.extra_option]
448
+ names = list(dict.fromkeys([*configured, *added], True))
449
+ unlisted = [name for name in names if name not in configured]
450
+ if unlisted and config_path is not None:
451
+ _warn(
452
+ f"--extra-option {', '.join(unlisted)}: not in the extra_options of "
453
+ f"[{SECTION}] in {config_path}. The build registers only the fields "
454
+ f"listed there, so a needimport of the produced file drops these; "
455
+ f"list them in the file."
456
+ )
457
+ return names
458
+
459
+
460
+ def _build_needs(arguments: argparse.Namespace) -> int:
461
+ section, config_path, error = _load_section(arguments)
462
+ if error:
463
+ print(error, file=sys.stderr)
464
+ return 2
465
+
466
+ settings, sources = _resolve_settings(
467
+ arguments, dict(needs_settings(section) or {})
468
+ )
469
+
470
+ # The loader already rejects a file whose build.needs.need_type disagrees
471
+ # with case's type. A --need-type flag (or the built-in default) is not in
472
+ # the file, so the merged value has to be checked here as well, or the
473
+ # converter writes needs of a type the build does not register. As in the
474
+ # loader, a side that is not set is compared at its default: a file that
475
+ # leaves case alone configures the build with the default type. Without a
476
+ # file the rule has nothing to hold against, and the output depends on
477
+ # the arguments alone.
478
+ if config_path is not None:
479
+ case_type = case_need_type(section)
480
+ if settings["need_type"] != (case_type or DEFAULT_NEED_TYPE):
481
+ build_side = (
482
+ f"[{SECTION}] case's type is {case_type!r}"
483
+ if case_type is not None
484
+ else f"[{SECTION}] does not set case, so the build's test-case "
485
+ f"type is the default {DEFAULT_NEED_TYPE!r},"
486
+ )
487
+ print(
488
+ f"error: {_spell('need_type', sources)} is "
489
+ f"{settings['need_type']!r} but {build_side} in {config_path}. "
490
+ f"Both name the need type of a test case -- need_type for this "
491
+ f"converter, case for the Sphinx build -- so they must agree.",
492
+ file=sys.stderr,
493
+ )
494
+ return 2
495
+
496
+ try:
497
+ link_properties = _parse_link_properties(settings["link_properties"])
498
+ except ValueError as error:
499
+ print(f"error: {error}", file=sys.stderr)
500
+ return 2
501
+
502
+ problem = check_url_pattern(str(settings["url_pattern"]))
503
+ if problem is not None:
504
+ # Checked before any report is read: str.format would otherwise fail
505
+ # on the first case that carries a file, as a traceback.
506
+ print(
507
+ f"error: {_spell('url_pattern', sources)} {settings['url_pattern']!r}: "
508
+ f"{problem}",
509
+ file=sys.stderr,
510
+ )
511
+ return 2
512
+
513
+ if bool(settings["remote_url"]) != bool(settings["commit"]):
514
+ print(
515
+ f"error: {_pair_requirement(sources, config_path)} must be given "
516
+ f"together; without both, no source URL can be synthesized.",
517
+ file=sys.stderr,
518
+ )
519
+ return 2
520
+
521
+ reports: list[Report] = []
522
+ for name in arguments.files:
523
+ path = Path(name)
524
+ if not path.is_file():
525
+ print(f"error: no such file: {path}", file=sys.stderr)
526
+ return 1
527
+ try:
528
+ # The parser is not typed yet (#114), so both calls are
529
+ # untyped to mypy; nothing to fix from this side.
530
+ parsed = JUnitParser(str(path)).parse()
531
+ except Exception as error:
532
+ print(f"error: {path}: {error}", file=sys.stderr)
533
+ return 1
534
+ _warn_about_empty_report(path, parsed)
535
+ _warn_about_absent_source_lines(path, parsed)
536
+ # The path as given, like the build records the path given to its
537
+ # directives; it is a label for the report, not something resolved.
538
+ reports.append((str(path), parsed))
539
+
540
+ try:
541
+ payload = build_needs_file(
542
+ reports,
543
+ project=_text(settings["project"]),
544
+ version=_text(settings["version"]),
545
+ need_type=_text(settings["need_type"]),
546
+ tags=_texts(settings["tags"]),
547
+ link_properties=link_properties,
548
+ base_url=normalise_remote_url(_text(settings["remote_url"])),
549
+ commit=_text(settings["commit"]),
550
+ url_pattern=_text(settings["url_pattern"]),
551
+ # The renameable field names and the accepted extra fields come
552
+ # from the same section the build reads, so an imported need has
553
+ # the shape of a local one.
554
+ fields=field_names(section),
555
+ extra_options=_extra_options(arguments, section, config_path),
556
+ warn=_warn,
557
+ )
558
+ except ValueError as error: # duplicate test cases across the inputs
559
+ print(f"error: {error}", file=sys.stderr)
560
+ return 2
561
+
562
+ output = Path(arguments.output)
563
+ output.parent.mkdir(parents=True, exist_ok=True)
564
+ # Sorted keys and a fixed indent keep the output byte-stable across runs.
565
+ output.write_text(
566
+ json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
567
+ )
568
+ return 0
569
+
570
+
571
+ def main(argv: "list[str] | None" = None) -> int:
572
+ """Entry point. Returns a process exit code instead of raising."""
573
+ arguments = _build_parser().parse_args(argv)
574
+ if arguments.command == "build" and arguments.artifact == "needs":
575
+ return _build_needs(arguments)
576
+ return 2 # pragma: no cover - argparse rejects unknown commands
577
+
578
+
579
+ if __name__ == "__main__":
580
+ sys.exit(main())