pyarchivist 2.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.
@@ -0,0 +1,4 @@
1
+ """Wikimedia Commons archive subpackage."""
2
+
3
+ """Public API exports (none)."""
4
+ __all__ = ()
@@ -0,0 +1,38 @@
1
+ """Entry point for the `Wikimedia_Commons` subcommand.
2
+
3
+ This module is executed when `python -m pyarchivist.Wikimedia_Commons` is run.
4
+ It constructs the subcommand parser and invokes the selected action.
5
+ """
6
+
7
+ from logging import INFO, basicConfig
8
+ from sys import argv
9
+
10
+ from asyncer import runnify
11
+
12
+ from .main import parser
13
+
14
+ """Public symbols exported by this module."""
15
+ __all__ = ("main",)
16
+
17
+
18
+ async def main() -> None:
19
+ """Internal async entry point for the Wikimedia_Commons subcommand."""
20
+ """Main entry point for the Wikimedia_Commons subcommand.
21
+
22
+ This function is called when the module is executed as a script. It sets up
23
+ logging, parses command-line arguments, and runs the selected action.
24
+
25
+ This function is wrapped by the synchronous `main` function to allow
26
+ asynchronous execution without requiring callers to use `anyio.run` directly."""
27
+ basicConfig(level=INFO)
28
+ entry = parser().parse_args(argv[1:])
29
+ await entry.invoke(entry)
30
+
31
+
32
+ def __main__() -> None:
33
+ """Synchronous command-line entrypoint exposed by the package."""
34
+ runnify(main, backend_options={"use_uvloop": True})()
35
+
36
+
37
+ if __name__ == "__main__":
38
+ __main__()
@@ -0,0 +1,471 @@
1
+ """Wikimedia Commons archive implementation.
2
+
3
+ This module implements the query, fetch and indexing flow for files on
4
+ Wikimedia Commons. It provides a top-level `main` coroutine and a `parser`
5
+ factory for the CLI subcommand. Helper utilities and small types used across
6
+ the flow are declared here as well.
7
+ """
8
+
9
+ from argparse import ONE_OR_MORE, ArgumentParser
10
+ from collections.abc import Callable, Collection, Iterable, Sequence
11
+ from dataclasses import dataclass
12
+ from enum import IntFlag, auto, unique
13
+ from functools import wraps
14
+ from itertools import chain
15
+ from re import MULTILINE, compile, sub
16
+ from sys import exit
17
+ from typing import ClassVar, Protocol, TypeVar, final
18
+ from urllib.parse import quote, unquote
19
+
20
+ from aiohttp import ClientSession, TCPConnector
21
+ from anyio import Path
22
+ from asyncer import SoonValue, asyncify, create_task_group
23
+ from html2text import HTML2Text
24
+ from yarl import URL
25
+
26
+ from pyarchivist.meta import LOGGER, OPEN_TEXT_OPTIONS, USER_AGENT, VERSION
27
+
28
+ from .models import Page, ResponseModel
29
+
30
+ """Public symbols exported by this module."""
31
+ __all__ = (
32
+ "ExitCode",
33
+ "Args",
34
+ "main",
35
+ "parser",
36
+ )
37
+
38
+ """Maximum concurrent HTTP requests per host for the aiohttp connector."""
39
+ _MAX_CONCURRENT_REQUESTS_PER_HOST = 1
40
+ """Characters left unescaped in URL percent-encoding for Commons URLs."""
41
+ _PERCENT_ESCAPE_SAFE = "/,"
42
+ """Number of page titles per API query batch."""
43
+ _QUERY_LIMIT = 50
44
+ """Type variable for generic helpers in this module."""
45
+ _T = TypeVar("_T")
46
+
47
+
48
+ @final
49
+ @unique
50
+ class ExitCode(IntFlag):
51
+ """Exit codes representing various error and partial-error conditions.
52
+
53
+ Each member corresponds to a phase or a class of failure that may occur
54
+ during a run. The bits can be combined to represent multiple simultaneous
55
+ failure modes (for example partial fetch errors plus a generic error).
56
+ """
57
+
58
+ __slots__: ClassVar = ()
59
+
60
+ GENERIC_ERROR = auto()
61
+ QUERY_ERROR = auto()
62
+ FETCH_ERROR = auto()
63
+ INDEX_ERROR = auto()
64
+ QUERY_ERROR_PARTIAL = auto()
65
+ FETCH_ERROR_PARTIAL = auto()
66
+
67
+
68
+ @final
69
+ @dataclass(
70
+ init=True,
71
+ repr=True,
72
+ eq=True,
73
+ order=False,
74
+ unsafe_hash=False,
75
+ frozen=True,
76
+ match_args=True,
77
+ kw_only=True,
78
+ slots=True,
79
+ )
80
+ class Args:
81
+ """Immutable container for parsed CLI arguments.
82
+
83
+ Attributes:
84
+ inputs: sequence of input titles (strings)
85
+ dest: destination path where files will be written
86
+ index: optional index file path (Markdown)
87
+ ignore_individual_errors: if True, continue on individual file errors
88
+ """
89
+
90
+ inputs: Sequence[str]
91
+ dest: Path
92
+ index: Path | None
93
+ ignore_individual_errors: bool
94
+
95
+ def __post_init__(self):
96
+ """Normalize `inputs` to an immutable tuple after initialization."""
97
+ object.__setattr__(self, "inputs", tuple(self.inputs))
98
+
99
+
100
+ # The CLI response models are provided by pydantic
101
+ # models in :mod:`.models` (see `Args` and `ResponseModel`). This keeps
102
+ # JSON parsing and validation centralized and more explicit.
103
+
104
+ """Regex matching index.md lines: - [display](url): credit."""
105
+ _INDEX_FORMAT_PATTERN = compile(r"^- \[(.+?(?<!\\))]\((.+?(?<!\\))\): (.+)$", MULTILINE)
106
+
107
+
108
+ def _handle_partial_errors(
109
+ results: Collection[_T | BaseException],
110
+ *,
111
+ ignore_individual_errors: bool,
112
+ error_message: str = "Error",
113
+ ) -> tuple[bool, Collection[_T]]:
114
+ """Inspect a collection of results and propagate or aggregate errors.
115
+
116
+ The `results` iterable may contain successful values or exception
117
+ instances (when `gather(..., return_exceptions=True)` was used). This
118
+ helper separates exceptions from values, optionally logs/raises grouped
119
+ exceptions and returns a tuple `(error_flag, successful_results)` where
120
+ `error_flag` is True when any exceptions were encountered and swallowed
121
+ due to `ignore_individual_errors=True`.
122
+ """
123
+
124
+ error = False
125
+ base_exceptions = tuple(
126
+ query for query in results if isinstance(query, BaseException)
127
+ )
128
+ exceptions = tuple(exc for exc in base_exceptions if isinstance(exc, Exception))
129
+ if len(exceptions) < len(base_exceptions):
130
+ raise BaseExceptionGroup(error_message, base_exceptions)
131
+ if exceptions:
132
+ exception_group = ExceptionGroup(error_message, exceptions)
133
+ if not ignore_individual_errors:
134
+ raise exception_group
135
+ try:
136
+ raise exception_group
137
+ except ExceptionGroup:
138
+ LOGGER.exception(error_message)
139
+ error = True
140
+ return error, tuple(
141
+ result for result in results if not isinstance(result, BaseException)
142
+ )
143
+
144
+
145
+ def _index_formatter(filename: str, credit: str):
146
+ """Format a Markdown index line for a file and its credit string.
147
+
148
+ The filename is escaped for Markdown compatibility and URL-escaped for
149
+ the link target. The returned string is suitable for appending to an
150
+ `index.md` paragraph handled by the indexing logic.
151
+ """
152
+
153
+ escaped = filename.replace("\\", "\\\\").replace("]", "\\]")
154
+ return f"- [{escaped}]({quote(filename, safe=_PERCENT_ESCAPE_SAFE)}): {credit}"
155
+
156
+
157
+ def _credit_formatter(page: Page):
158
+ """Produce a credit string (HTML fragment) for an image page.
159
+
160
+ The function extracts author and license information from the page
161
+ metadata, sanitizes common 'Unknown' markers and returns a concise
162
+ HTML snippet linking back to the file description page on Commons.
163
+ """
164
+
165
+ assert page.imageinfo is not None
166
+
167
+ htm_esc = HTML2Text()
168
+ htm_esc.emphasis_mark = "_"
169
+ htm_esc.ignore_links = True
170
+ htm_esc.single_line_break = True
171
+ htm_esc.strong_mark = "__"
172
+ htm_esc.ul_item_mark = "-"
173
+
174
+ ii = page.imageinfo[0]
175
+ emd = ii.extmetadata
176
+
177
+ # Defensive access: pydantic fields may be None; ensure we pass `str` to
178
+ # html2text and call string methods only on `str`.
179
+ raw_author = ""
180
+ if emd and emd.Artist and emd.Artist.value:
181
+ raw_author = emd.Artist.value
182
+ author = htm_esc.handle(raw_author).strip()
183
+ # html2text may convert tags to emphasis markers (we use `_` / `__`).
184
+ # treat values that are only emphasis markers or whitespace as absent so
185
+ # entirely-tagged authors (e.g. "<b> </b>") fall back to the default.
186
+ if not author.replace("_", "").strip():
187
+ author = ""
188
+
189
+ # Strip literal HTML tags from the text (fallback for malformed input like
190
+ # "A<>>" so the normalization path remains predictable).
191
+ author = sub(r"<[^>]*>", "", author).strip()
192
+
193
+ if "Unknown author".casefold() in author.casefold():
194
+ author = ""
195
+
196
+ raw_lic = ""
197
+ if emd and emd.LicenseShortName and emd.LicenseShortName.value:
198
+ raw_lic = emd.LicenseShortName.value
199
+ # treat whitespace-only values as absent
200
+ lic = raw_lic.strip()
201
+ if "Unknown license".casefold() in lic.casefold():
202
+ lic = ""
203
+
204
+ lic_url = ""
205
+ if emd and emd.LicenseUrl and emd.LicenseUrl.value:
206
+ lic_url = emd.LicenseUrl.value
207
+
208
+ lic_lnk = "".join(
209
+ (
210
+ f'<a href="{lic_url}">' if lic_url else "",
211
+ (lic.replace("\n", "") if lic else "See page for license"),
212
+ "</a>" if lic_url else "",
213
+ )
214
+ )
215
+
216
+ author = author.replace("\n", "") or "See page for author"
217
+ return (
218
+ f'<a href="{ii.descriptionurl}">{author}</a>, {lic_lnk}, via Wikimedia Commons'
219
+ )
220
+
221
+
222
+ async def main(args: Args):
223
+ """Primary coroutine implementing the query-fetch-index flow.
224
+
225
+ Executes the following steps:
226
+ 1. Query Wikimedia Commons for page and image metadata for requested inputs.
227
+ 2. Fetch image binary content for the discovered pages.
228
+ 3. Optionally update a Markdown index file using `_index_formatter`.
229
+
230
+ On error, the function will log and set appropriate `ExitCode` flags before
231
+ calling `sys.exit` with the resulting exit code.
232
+ """
233
+
234
+ ec = ExitCode(0)
235
+
236
+ try:
237
+ inputs = tuple(dict.fromkeys(args.inputs))
238
+ async with ClientSession(
239
+ connector=TCPConnector(limit_per_host=_MAX_CONCURRENT_REQUESTS_PER_HOST),
240
+ headers={
241
+ "Accept-Encoding": "gzip",
242
+ "User-Agent": USER_AGENT,
243
+ },
244
+ ) as sess:
245
+ try:
246
+ LOGGER.info(f"Querying {len(inputs)} files")
247
+
248
+ async def query(
249
+ inputs: Iterable[str],
250
+ ) -> Iterable[tuple[str, Page]] | BaseException:
251
+ """Query the Wikimedia Commons API for the given titles and
252
+ return the parsed pages items, or the exception if the query fails.
253
+ """
254
+ try:
255
+ async with sess.get(
256
+ URL.build(
257
+ scheme="https",
258
+ host="commons.wikimedia.org",
259
+ path="/w/api.php",
260
+ query={
261
+ "format": "json",
262
+ "action": "query",
263
+ "titles": "|".join(inputs),
264
+ "prop": "imageinfo",
265
+ "iiprop": "extmetadata|url",
266
+ },
267
+ )
268
+ ) as resp:
269
+ text = await resp.text()
270
+ data = ResponseModel.model_validate_json(text)
271
+ return data.query.pages.items()
272
+ except BaseException as e:
273
+ return e
274
+
275
+ # run query batches concurrently using structural concurrency
276
+ # run query batches concurrently using soonify for brevity
277
+ # run query batches concurrently using structural concurrency
278
+ # the ``SoonValue`` objects capture return values that we can
279
+ # inspect after the task group closes (see Asyncer documentation).
280
+ svs: list[SoonValue[Iterable[tuple[str, Page]] | BaseException]] = []
281
+ async with create_task_group() as tg:
282
+ for idx in range(0, len(inputs), _QUERY_LIMIT):
283
+ svs.append(tg.soonify(query)(inputs[idx : idx + _QUERY_LIMIT]))
284
+ # at this point the task group has exited and all queries have
285
+ # completed; we can safely access ``.value`` on each SoonValue.
286
+ queries = [sv.value for sv in svs]
287
+ error, queries = _handle_partial_errors(
288
+ queries,
289
+ ignore_individual_errors=args.ignore_individual_errors,
290
+ error_message="Error querying",
291
+ )
292
+ if error:
293
+ ec |= ExitCode.QUERY_ERROR_PARTIAL
294
+ # ``id`` is a builtin, so rename to ``page_id`` for clarity and
295
+ # to give mypy/pyright an explicit variable name.
296
+ pages = tuple(
297
+ {
298
+ page_id: page for page_id, page in chain.from_iterable(queries)
299
+ }.values()
300
+ )
301
+ except Exception:
302
+ LOGGER.exception("Error querying")
303
+ ec |= ExitCode.QUERY_ERROR
304
+ raise
305
+ try:
306
+ LOGGER.info(f"Fetching {len(pages)} files")
307
+
308
+ async def fetch(page: Page) -> tuple[str, str] | BaseException:
309
+ """Download the binary content for ``page`` and return a tuple of
310
+ ``(filename, index_line)``, or the exception if the fetch fails.
311
+ """
312
+ try:
313
+ filename = page.title.split(":", 1)[-1]
314
+ if page.imageinfo is None:
315
+ raise ValueError(f"Failed to fetch '{filename}'")
316
+ dest_path = args.dest
317
+ # ensure destination directory exists before writing files
318
+ await dest_path.mkdir(parents=True, exist_ok=True)
319
+ async with (
320
+ sess.get(page.imageinfo[0].url) as resp,
321
+ await (dest_path / filename).open(mode="wb") as file,
322
+ ):
323
+ LOGGER.info(f"Fetching '{filename}'")
324
+ async for chunk in resp.content.iter_any():
325
+ await file.write(chunk)
326
+ # compute credit/index lines off the event loop
327
+ credit = await asyncify(_credit_formatter)(page)
328
+ index_line = await asyncify(_index_formatter)(filename, credit)
329
+ return filename, index_line
330
+ except BaseException as e:
331
+ return e
332
+
333
+ # fetch pages concurrently, capturing their return values with
334
+ # SoonValue so we can access them after the task group exits.
335
+ fetch_svs: list[SoonValue[tuple[str, str] | BaseException]] = []
336
+ async with create_task_group() as tg:
337
+ for page in pages:
338
+ fetch_svs.append(tg.soonify(fetch)(page))
339
+ entries = [sv.value for sv in fetch_svs]
340
+ error, entries = _handle_partial_errors(
341
+ entries,
342
+ ignore_individual_errors=args.ignore_individual_errors,
343
+ error_message="Error fetching",
344
+ )
345
+ if error:
346
+ ec |= ExitCode.FETCH_ERROR_PARTIAL
347
+ except Exception:
348
+ LOGGER.info("Error fetching")
349
+ ec |= ExitCode.FETCH_ERROR
350
+ raise
351
+ try:
352
+ if args.index is None:
353
+ LOGGER.info("Skipped indexing")
354
+ else:
355
+ LOGGER.info(f"Indexing {len(entries)} files")
356
+
357
+ idx = args.index
358
+ await idx.parent.mkdir(parents=True, exist_ok=True)
359
+ try:
360
+ file = await idx.open(mode="xt", **OPEN_TEXT_OPTIONS)
361
+ except FileExistsError:
362
+ pass
363
+ else:
364
+ await file.aclose()
365
+
366
+ async with await idx.open(mode="r+t", **OPEN_TEXT_OPTIONS) as file:
367
+ read = await file.read()
368
+ await file.seek(0)
369
+ paragraphs = read.strip().split("\n\n")
370
+ index: dict[str, str] = {
371
+ unquote(match[2]): match[0]
372
+ for match in _INDEX_FORMAT_PATTERN.finditer(paragraphs[-1])
373
+ }
374
+ for filename, entry in entries:
375
+ index[filename] = entry
376
+ paragraphs[-1] = "\n".join(
377
+ value
378
+ for _, value in sorted(
379
+ index.items(), key=lambda item: item[0]
380
+ )
381
+ )
382
+ text = "\n\n".join(paragraphs) + "\n"
383
+ await file.write(text)
384
+ await file.truncate()
385
+ except Exception:
386
+ LOGGER.exception("Error indexing")
387
+ ec |= ExitCode.INDEX_ERROR
388
+ raise
389
+ except Exception:
390
+ LOGGER.exception("Error")
391
+ ec |= ExitCode.GENERIC_ERROR
392
+
393
+ exit(ec)
394
+
395
+
396
+ class _ParserNamespace(Protocol):
397
+ """Typed namespace returned by the Wikimedia subparser."""
398
+
399
+ dest: Path
400
+ index: Path | None
401
+ inputs: list[str]
402
+ ignore_individual_errors: bool
403
+
404
+
405
+ def parser(parent: Callable[..., ArgumentParser] | None = None):
406
+ """Return an argparse parser configured for the Wikimedia Commons subcommand.
407
+
408
+ When embedded, `parent` can be a callable that produces an `ArgumentParser`.
409
+ """
410
+
411
+ prog = __package__ or __name__
412
+
413
+ parser = (ArgumentParser if parent is None else parent)(
414
+ prog=f"python -m {prog}",
415
+ description="archive data from Wikimedia Commons",
416
+ add_help=True,
417
+ allow_abbrev=False,
418
+ exit_on_error=False,
419
+ )
420
+ parser.add_argument(
421
+ "-v",
422
+ "--version",
423
+ action="version",
424
+ version=f"{prog} v{VERSION}",
425
+ help="print version and exit",
426
+ )
427
+ parser.add_argument(
428
+ "-d",
429
+ "--dest",
430
+ action="store",
431
+ type=Path,
432
+ required=True,
433
+ help="destination directory",
434
+ )
435
+ parser.add_argument(
436
+ "-i",
437
+ "--index",
438
+ action="store",
439
+ type=Path,
440
+ help="Markdown-based index file",
441
+ )
442
+ parser.add_argument(
443
+ "--ignore-individual-errors",
444
+ action="store_true",
445
+ default=False,
446
+ help="ignore errors from individual files",
447
+ dest="ignore_individual_errors",
448
+ )
449
+ parser.add_argument(
450
+ "inputs",
451
+ action="store",
452
+ nargs=ONE_OR_MORE,
453
+ type=str,
454
+ help="sequence of input(s) to read",
455
+ )
456
+
457
+ @wraps(main)
458
+ async def invoke(args: _ParserNamespace):
459
+ """Adapter converting an argparse namespace into `Args` and calling
460
+ `main`."""
461
+ await main(
462
+ Args(
463
+ inputs=tuple(args.inputs),
464
+ dest=args.dest,
465
+ index=args.index,
466
+ ignore_individual_errors=args.ignore_individual_errors,
467
+ )
468
+ )
469
+
470
+ parser.set_defaults(invoke=invoke)
471
+ return parser
@@ -0,0 +1,78 @@
1
+ """Pydantic models for Wikimedia Commons API responses and CLI arguments.
2
+
3
+ This module centralizes strongly-typed models used by the Wikimedia Commons
4
+ subcommand. Using pydantic ensures that JSON responses are validated and
5
+ that CLI arguments are validated and canonicalized in a single place.
6
+ """
7
+
8
+ from collections.abc import Mapping, Sequence
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ """Public symbols exported by this module."""
13
+ __all__ = (
14
+ "Value",
15
+ "ExtMetadata",
16
+ "ImageInfoEntry",
17
+ "Page",
18
+ "Query",
19
+ "ResponseModel",
20
+ )
21
+
22
+
23
+ class Value(BaseModel):
24
+ """A small container providing a textual ``value`` and its ``source``.
25
+
26
+ Mirrors the small value structures returned in extended metadata blocks
27
+ from the MediaWiki API (for example ``Artist`` and ``LicenseUrl``).
28
+ """
29
+
30
+ value: str | None = None
31
+ source: str | None = None
32
+
33
+ model_config = ConfigDict(frozen=True)
34
+
35
+
36
+ class ExtMetadata(BaseModel):
37
+ """Extended metadata block containing author and license information."""
38
+
39
+ Artist: Value | None = None
40
+ LicenseShortName: Value | None = None
41
+ LicenseUrl: Value | None = None
42
+
43
+ model_config = ConfigDict(frozen=True)
44
+
45
+
46
+ class ImageInfoEntry(BaseModel):
47
+ """Describes a single image variant including URL and metadata."""
48
+
49
+ descriptionurl: str
50
+ extmetadata: ExtMetadata | None = None
51
+ url: str
52
+
53
+ model_config = ConfigDict(frozen=True)
54
+
55
+
56
+ class Page(BaseModel):
57
+ """A page container with a title and optional image information."""
58
+
59
+ title: str
60
+ imageinfo: Sequence[ImageInfoEntry] | None = None
61
+
62
+ model_config = ConfigDict(frozen=True)
63
+
64
+
65
+ class Query(BaseModel):
66
+ """Top-level query mapping containing page id -> :class:`Page`."""
67
+
68
+ pages: Mapping[str, Page]
69
+
70
+ model_config = ConfigDict(frozen=True)
71
+
72
+
73
+ class ResponseModel(BaseModel):
74
+ """Top-level response model for the MediaWiki API query flow."""
75
+
76
+ query: Query
77
+
78
+ model_config = ConfigDict(frozen=True)
@@ -0,0 +1,11 @@
1
+ """Minimal package surface for `pyarchivist`.
2
+
3
+ This package intentionally avoids re-exporting module internals from
4
+ `__init__`. Modules should import package metadata and configuration from
5
+ `pyarchivist.meta` (for example: ``from pyarchivist.meta import VERSION``).
6
+ """
7
+
8
+ from .meta import VERSION as __version__
9
+
10
+ """Package public API exports."""
11
+ __all__ = ("__version__",)
@@ -0,0 +1,35 @@
1
+ """Module entry-point for command-line invocation.
2
+
3
+ This module is executed when the package is run with `python -m pyarchivist`.
4
+ It configures basic logging and executes the selected CLI subcommand.
5
+ """
6
+
7
+ from logging import INFO, basicConfig
8
+ from sys import argv
9
+
10
+ from asyncer import runnify
11
+
12
+ from .main import parser
13
+
14
+ """Public symbols exported by this module."""
15
+ __all__ = ("main",)
16
+
17
+
18
+ async def main() -> None:
19
+ """Main entry point for the pyarchivist command-line interface.
20
+
21
+ This function is called when the module is executed as a script. It sets up
22
+ logging, parses command-line arguments, and runs the selected action.
23
+ """
24
+ basicConfig(level=INFO)
25
+ entry = parser().parse_args(argv[1:])
26
+ await entry.invoke(entry)
27
+
28
+
29
+ def __main__() -> None:
30
+ """Synchronous command-line entrypoint exposed by the package."""
31
+ runnify(main, backend_options={"use_uvloop": True})()
32
+
33
+
34
+ if __name__ == "__main__":
35
+ __main__()
pyarchivist/main.py ADDED
@@ -0,0 +1,55 @@
1
+ """CLI utilities for pyarchivist.
2
+
3
+ This module provides the top-level CLI `ArgumentParser` factory and wires
4
+ in subcommands from subpackages.
5
+ """
6
+
7
+ from argparse import ArgumentParser
8
+ from collections.abc import Callable
9
+ from functools import partial
10
+
11
+ from pyarchivist.meta import VERSION
12
+
13
+ from .Wikimedia_Commons import __name__ as Wikimedia_Commons_name
14
+ from .Wikimedia_Commons import __package__ as Wikimedia_Commons_package
15
+ from .Wikimedia_Commons.main import parser as Wikimedia_Commons_parser
16
+
17
+ """Public symbols exported by this module."""
18
+ __all__ = ("parser",)
19
+
20
+
21
+ def parser(parent: Callable[..., ArgumentParser] | None = None):
22
+ """Return an ArgumentParser configured for the package CLI.
23
+
24
+ If a `parent` callable is provided it will be used to construct the
25
+ parser (useful when the command is embedded within another parser).
26
+ """
27
+
28
+ prog = __package__ or __name__
29
+
30
+ parser = (ArgumentParser if parent is None else parent)(
31
+ prog=f"python -m {prog}",
32
+ description="archive data",
33
+ add_help=True,
34
+ allow_abbrev=False,
35
+ exit_on_error=False,
36
+ )
37
+ parser.add_argument(
38
+ "-v",
39
+ "--version",
40
+ action="version",
41
+ version=f"{prog} v{VERSION}",
42
+ help="print version and exit",
43
+ )
44
+ subparsers = parser.add_subparsers(
45
+ required=True,
46
+ )
47
+ Wikimedia_Commons_parser(
48
+ partial(
49
+ subparsers.add_parser,
50
+ (Wikimedia_Commons_package or Wikimedia_Commons_name).replace(
51
+ f"{prog}.", ""
52
+ ),
53
+ )
54
+ )
55
+ return parser