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.
- pyarchivist/Wikimedia_Commons/__init__.py +4 -0
- pyarchivist/Wikimedia_Commons/__main__.py +38 -0
- pyarchivist/Wikimedia_Commons/main.py +471 -0
- pyarchivist/Wikimedia_Commons/models.py +78 -0
- pyarchivist/__init__.py +11 -0
- pyarchivist/__main__.py +35 -0
- pyarchivist/main.py +55 -0
- pyarchivist/meta.py +68 -0
- pyarchivist/py.typed +0 -0
- pyarchivist-2.1.0.dist-info/METADATA +730 -0
- pyarchivist-2.1.0.dist-info/RECORD +13 -0
- pyarchivist-2.1.0.dist-info/WHEEL +4 -0
- pyarchivist-2.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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)
|
pyarchivist/__init__.py
ADDED
|
@@ -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__",)
|
pyarchivist/__main__.py
ADDED
|
@@ -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
|