xdfetch 0.0.1__tar.gz

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,100 @@
1
+ name: release
2
+
3
+ # Pushing a vX.Y.Z tag on master uploads that version to PyPI, then creates
4
+ # the GitHub release with generated notes and the sdist and wheel attached.
5
+ #
6
+ # 1. bump __version__ in xdfetch/__init__.py and merge that to master
7
+ # 2. git tag vX.Y.Z <that commit> && git push origin vX.Y.Z
8
+ #
9
+ # Uploads use PyPI trusted publishing (no API token). One-time setup on
10
+ # pypi.org, by a project owner: Manage project -> Publishing -> add a GitHub
11
+ # publisher with
12
+ # owner: century-arcade, repository: xdfetch,
13
+ # workflow: release.yml, environment: pypi
14
+ # GitHub creates the "pypi" environment on the first run if it doesn't
15
+ # exist. A repo admin can add protection rules to it, such as a required
16
+ # reviewer to approve each upload.
17
+ #
18
+ # If a job fails, fix the cause and re-run the workflow: the upload skips
19
+ # files PyPI already has, and the release step updates an existing release.
20
+
21
+ on:
22
+ push:
23
+ tags: ["v*"]
24
+
25
+ jobs:
26
+ build:
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v7
30
+ with:
31
+ fetch-depth: 0 # full history, for the on-master check below
32
+ - uses: actions/setup-python@v7
33
+ with:
34
+ python-version: "3.14"
35
+ - name: check that the tag matches the package version
36
+ run: |
37
+ version=$(python -c "import xdfetch; print(xdfetch.__version__)")
38
+ if [ "$GITHUB_REF_NAME" != "v$version" ]; then
39
+ echo "tag $GITHUB_REF_NAME does not match __version__ $version (expected v$version)"
40
+ exit 1
41
+ fi
42
+ - name: check that the tagged commit is on master
43
+ run: |
44
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
45
+ echo "tag $GITHUB_REF_NAME points at $GITHUB_SHA, which is not on master"
46
+ exit 1
47
+ fi
48
+ - run: pip install build twine
49
+ - run: python -m build
50
+ - run: twine check --strict dist/*
51
+ - name: run the tests against the built wheel
52
+ run: |
53
+ pip install "$(ls dist/*.whl)" pytest
54
+ # run from outside the checkout so the tests import the installed wheel
55
+ cd "$RUNNER_TEMP"
56
+ pytest "$GITHUB_WORKSPACE/tests"
57
+ - uses: actions/upload-artifact@v7
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+
62
+ publish:
63
+ needs: build
64
+ runs-on: ubuntu-latest
65
+ environment:
66
+ name: pypi
67
+ url: https://pypi.org/p/xdfetch
68
+ permissions:
69
+ id-token: write
70
+ steps:
71
+ - uses: actions/download-artifact@v8
72
+ with:
73
+ name: dist
74
+ path: dist/
75
+ - uses: pypa/gh-action-pypi-publish@release/v1
76
+ with:
77
+ skip-existing: true
78
+
79
+ github-release:
80
+ needs: publish
81
+ runs-on: ubuntu-latest
82
+ permissions:
83
+ contents: write
84
+ env:
85
+ GH_TOKEN: ${{ github.token }}
86
+ GH_REPO: ${{ github.repository }}
87
+ steps:
88
+ - uses: actions/download-artifact@v8
89
+ with:
90
+ name: dist
91
+ path: dist/
92
+ - name: create the release, or update it on a re-run
93
+ run: |
94
+ if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
95
+ gh release upload "$GITHUB_REF_NAME" dist/* --clobber
96
+ else
97
+ gh release create "$GITHUB_REF_NAME" dist/* \
98
+ --verify-tag --generate-notes \
99
+ --title "xdfetch ${GITHUB_REF_NAME#v}"
100
+ fi
@@ -0,0 +1,18 @@
1
+ name: test
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ pytest:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.10", "3.14"]
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ - uses: actions/setup-python@v7
14
+ with:
15
+ python-version: ${{ matrix.python-version }}
16
+ - run: pip install -e . pytest ruff
17
+ - run: ruff check
18
+ - run: pytest
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,74 @@
1
+ # xdfetch design
2
+
3
+ Started 2026-11-09. This is a new implementation. The design comes from what we learned
4
+ running a private daily downloader for the xd corpus, but no code is carried over, so
5
+ everything here is written fresh and can be published.
6
+
7
+ ## Goals
8
+
9
+ - **Something anyone can use for one puzzle.** `xdfetch get <outlet>` fetches, converts and
10
+ writes a `.xd` in the current directory. No setup, no state.
11
+ - **Something an archivist can run every day.** `xdfetch run` keeps every file as served in
12
+ a store (a directory or an `s3://` prefix), with a ledger of what was fetched, so a run
13
+ that was killed resumes where it stopped and a later run can look again for corrections.
14
+ - **Full fidelity.** Rebus squares, circles, shading and clue markup make it into the
15
+ `.xd`. What .xd can't hold is listed, not silently dropped.
16
+ - **Room for outlets that aren't in this repo.** Other packages can add adapters,
17
+ converters and outlet rows through entry points, without forking.
18
+
19
+ ## Pieces
20
+
21
+ | Module | Job |
22
+ |---|---|
23
+ | `http.py` | One session per host, the tool's own User-Agent, a rate limit, backoff. |
24
+ | `registry.py` + `outlets.tsv` | One row per outlet: adapter, params, publication days, time zone, when the file goes up, when to look again for corrections, homepage. |
25
+ | `adapters/` | One per *transport* (a plain URL, Amuse Labs, Puzzmo, ...): for a date, where the file is, then fetch it as served. |
26
+ | `convert/` | One per *format* (uclick XML, Amuse JSON, ipuz, .puz, ...), chosen by sniffing the bytes, never by file extension. Each builds a `Puzzle`. |
27
+ | `xd.py` | `Puzzle` to `.xd` text. |
28
+ | `store.py`, `ledger.py`, `fetch.py` | The archivist's mode: plan (expected minus already fetched), run, record. |
29
+ | `cli.py` | `list`, `get`, `run`, `plan`, `status`, `convert`. |
30
+
31
+ ## Extension points
32
+
33
+ Other packages add to xdfetch by declaring entry points:
34
+
35
+ ```toml
36
+ [project.entry-points."xdfetch.adapters"]
37
+ myhost = "mypkg.adapters:MyHostAdapter"
38
+
39
+ [project.entry-points."xdfetch.converters"]
40
+ myformat = "mypkg.convert:MY_FORMAT" # sniff + convert
41
+
42
+ [project.entry-points."xdfetch.registries"]
43
+ mine = "mypkg:OUTLETS_TSV" # extra outlet rows, and overrides of built-in ones
44
+ ```
45
+
46
+ A later registry row with the same id replaces an earlier one whole.
47
+
48
+ ## Outlet ids
49
+
50
+ Outlet ids follow the xd corpus's publication ids (`nyt`, `lat`, `wsj`, `up`, ...), so a
51
+ fetched puzzle's filename is the name the corpus would give it.
52
+
53
+ ## Tests
54
+
55
+ Converter tests use small fixtures written for the test (a few squares and clues), not
56
+ real puzzles: published puzzles are copyrighted, and this repo is public. Live checks
57
+ against outlets are opt-in and never run in CI.
58
+
59
+ ## Etiquette
60
+
61
+ - The User-Agent names the tool and its repo, nothing else.
62
+ - One request at a time per host, with a delay, and backoff on errors.
63
+ - Outlets that need a subscription use the user's own credentials, from the environment.
64
+ The tool never gets around a paywall.
65
+
66
+ ## Open questions
67
+
68
+ - Use xdformat's parser and writer for `.xd`, or a small writer of our own? xdformat
69
+ would keep one definition of the format; a writer of our own keeps the dependency list
70
+ short.
71
+ - Typography: write the outlet's own characters, or the corpus's house style (curly quotes
72
+ flattened and so on)? Probably the corpus's style by default, with a switch.
73
+ - Other output formats (`.ipuz`, `.puz` for solving apps): later, if people ask.
74
+ - Which outlets start here and which are added by other packages.
xdfetch-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 century-arcade
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
xdfetch-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: xdfetch
3
+ Version: 0.0.1
4
+ Summary: Fetch crossword puzzles from online outlets and convert them to .xd
5
+ Project-URL: Homepage, https://github.com/century-arcade/xdfetch
6
+ Project-URL: Repository, https://github.com/century-arcade/xdfetch
7
+ Project-URL: Issues, https://github.com/century-arcade/xdfetch/issues
8
+ Author: Alex DeJarnatt
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: crossword,downloader,puzzles,xd
12
+ Classifier: Development Status :: 1 - Planning
13
+ Classifier: Environment :: Console
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Games/Entertainment :: Puzzle Games
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # xdfetch
22
+
23
+ Fetches crossword puzzles from the outlets that publish them online and converts them to
24
+ [.xd](https://github.com/century-arcade/xdformat), the plain-text crossword format of the
25
+ [xd corpus](https://github.com/century-arcade/xd).
26
+
27
+ **Status: just started.** The package installs and the command runs, but it can't fetch
28
+ anything yet. [DESIGN.md](DESIGN.md) has the plan.
29
+
30
+ ## What it will do
31
+
32
+ pip install xdfetch
33
+ xdfetch list # the outlets it knows
34
+ xdfetch get lat # today's LA Times -> lat2026-11-09.xd
35
+ xdfetch get nyt --date 2026-11-01 # with your own subscription cookie in NYT_S
36
+ xdfetch run --store ./archive --start 2026-10-01 --end 2026-10-31 # keep an archive
37
+
38
+ Three rules shape it:
39
+
40
+ 1. **Fetch keeps the bytes as served.** The file the outlet sent is kept as it was, and
41
+ conversion reads from that.
42
+ 2. **Convert is separate:** one converter per *format*, not per outlet, chosen by looking
43
+ at the content. Anything a source has that .xd can't carry is reported, never dropped
44
+ silently.
45
+ 3. **Most outlets are a row, not code.** `outlets.tsv` says where each outlet's file is and
46
+ when it publishes; code is only needed for a new kind of host.
47
+
48
+ ## Development
49
+
50
+ uv sync --group dev
51
+ uv run pytest
52
+ uv run ruff check
53
+
54
+ ## License
55
+
56
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,36 @@
1
+ # xdfetch
2
+
3
+ Fetches crossword puzzles from the outlets that publish them online and converts them to
4
+ [.xd](https://github.com/century-arcade/xdformat), the plain-text crossword format of the
5
+ [xd corpus](https://github.com/century-arcade/xd).
6
+
7
+ **Status: just started.** The package installs and the command runs, but it can't fetch
8
+ anything yet. [DESIGN.md](DESIGN.md) has the plan.
9
+
10
+ ## What it will do
11
+
12
+ pip install xdfetch
13
+ xdfetch list # the outlets it knows
14
+ xdfetch get lat # today's LA Times -> lat2026-11-09.xd
15
+ xdfetch get nyt --date 2026-11-01 # with your own subscription cookie in NYT_S
16
+ xdfetch run --store ./archive --start 2026-10-01 --end 2026-10-31 # keep an archive
17
+
18
+ Three rules shape it:
19
+
20
+ 1. **Fetch keeps the bytes as served.** The file the outlet sent is kept as it was, and
21
+ conversion reads from that.
22
+ 2. **Convert is separate:** one converter per *format*, not per outlet, chosen by looking
23
+ at the content. Anything a source has that .xd can't carry is reported, never dropped
24
+ silently.
25
+ 3. **Most outlets are a row, not code.** `outlets.tsv` says where each outlet's file is and
26
+ when it publishes; code is only needed for a new kind of host.
27
+
28
+ ## Development
29
+
30
+ uv sync --group dev
31
+ uv run pytest
32
+ uv run ruff check
33
+
34
+ ## License
35
+
36
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xdfetch"
7
+ dynamic = ["version"]
8
+ description = "Fetch crossword puzzles from online outlets and convert them to .xd"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Alex DeJarnatt" }]
14
+ keywords = ["crossword", "puzzles", "xd", "downloader"]
15
+ classifiers = [
16
+ "Development Status :: 1 - Planning",
17
+ "Environment :: Console",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Games/Entertainment :: Puzzle Games",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/century-arcade/xdfetch"
27
+ Repository = "https://github.com/century-arcade/xdfetch"
28
+ Issues = "https://github.com/century-arcade/xdfetch/issues"
29
+
30
+ [project.scripts]
31
+ xdfetch = "xdfetch.cli:main"
32
+
33
+ [dependency-groups]
34
+ dev = ["pytest>=8", "ruff>=0.8"]
35
+
36
+ [tool.hatch.version]
37
+ path = "xdfetch/__init__.py"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["xdfetch"]
41
+
42
+ [tool.ruff]
43
+ line-length = 120
44
+ target-version = "py310"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B", "W"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
@@ -0,0 +1,24 @@
1
+ import pytest
2
+
3
+ from xdfetch import __version__, registry
4
+ from xdfetch.cli import main
5
+
6
+
7
+ def test_version(capsys):
8
+ with pytest.raises(SystemExit) as e:
9
+ main(["--version"])
10
+ assert e.value.code == 0
11
+ assert __version__ in capsys.readouterr().out
12
+
13
+
14
+ def test_registry_loads():
15
+ outlets = registry.load()
16
+ assert isinstance(outlets, dict)
17
+
18
+
19
+ def test_list_runs():
20
+ assert main(["list"]) == 0
21
+
22
+
23
+ def test_get_not_written_yet():
24
+ assert main(["get", "lat"]) == 2
xdfetch-0.0.1/uv.lock ADDED
@@ -0,0 +1,202 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "exceptiongroup"
16
+ version = "1.3.1"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
20
+ ]
21
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
22
+ wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "iniconfig"
28
+ version = "2.3.1"
29
+ source = { registry = "https://pypi.org/simple" }
30
+ sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
33
+ ]
34
+
35
+ [[package]]
36
+ name = "packaging"
37
+ version = "26.3"
38
+ source = { registry = "https://pypi.org/simple" }
39
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
40
+ wheels = [
41
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
42
+ ]
43
+
44
+ [[package]]
45
+ name = "pluggy"
46
+ version = "1.7.0"
47
+ source = { registry = "https://pypi.org/simple" }
48
+ sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
49
+ wheels = [
50
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
51
+ ]
52
+
53
+ [[package]]
54
+ name = "pygments"
55
+ version = "2.21.0"
56
+ source = { registry = "https://pypi.org/simple" }
57
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
58
+ wheels = [
59
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
60
+ ]
61
+
62
+ [[package]]
63
+ name = "pytest"
64
+ version = "9.2.0"
65
+ source = { registry = "https://pypi.org/simple" }
66
+ dependencies = [
67
+ { name = "colorama", marker = "sys_platform == 'win32'" },
68
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
69
+ { name = "iniconfig" },
70
+ { name = "packaging" },
71
+ { name = "pluggy" },
72
+ { name = "pygments" },
73
+ { name = "tomli", marker = "python_full_version < '3.11'" },
74
+ ]
75
+ sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
76
+ wheels = [
77
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
78
+ ]
79
+
80
+ [[package]]
81
+ name = "ruff"
82
+ version = "0.17.4"
83
+ source = { registry = "https://pypi.org/simple" }
84
+ sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" }
85
+ wheels = [
86
+ { url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" },
87
+ { url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" },
88
+ { url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" },
89
+ { url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" },
90
+ { url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" },
91
+ { url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" },
92
+ { url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" },
93
+ { url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" },
94
+ { url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" },
95
+ { url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" },
96
+ { url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" },
97
+ { url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" },
98
+ { url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" },
99
+ { url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" },
100
+ { url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" },
101
+ { url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" },
102
+ { url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" },
103
+ ]
104
+
105
+ [[package]]
106
+ name = "tomli"
107
+ version = "2.5.0"
108
+ source = { registry = "https://pypi.org/simple" }
109
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
110
+ wheels = [
111
+ { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" },
112
+ { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" },
113
+ { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" },
114
+ { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" },
115
+ { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" },
116
+ { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" },
117
+ { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" },
118
+ { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" },
119
+ { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" },
120
+ { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
121
+ { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
122
+ { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
123
+ { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
124
+ { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
125
+ { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
126
+ { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
127
+ { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
128
+ { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
129
+ { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
130
+ { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
131
+ { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
132
+ { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
133
+ { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
134
+ { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
135
+ { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
136
+ { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
137
+ { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
138
+ { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
139
+ { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
140
+ { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
141
+ { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
142
+ { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
143
+ { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
144
+ { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
145
+ { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
146
+ { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
147
+ { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
148
+ { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
149
+ { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
150
+ { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
151
+ { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
152
+ { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
153
+ { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
154
+ { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
155
+ { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
156
+ { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
157
+ { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
158
+ { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
159
+ { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
160
+ { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
161
+ { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
162
+ { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
163
+ { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
164
+ { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
165
+ { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
166
+ { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
167
+ { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
168
+ { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
169
+ { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
170
+ { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
171
+ { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
172
+ { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
173
+ { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
174
+ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
175
+ ]
176
+
177
+ [[package]]
178
+ name = "typing-extensions"
179
+ version = "4.16.0"
180
+ source = { registry = "https://pypi.org/simple" }
181
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
182
+ wheels = [
183
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
184
+ ]
185
+
186
+ [[package]]
187
+ name = "xdfetch"
188
+ source = { editable = "." }
189
+
190
+ [package.dev-dependencies]
191
+ dev = [
192
+ { name = "pytest" },
193
+ { name = "ruff" },
194
+ ]
195
+
196
+ [package.metadata]
197
+
198
+ [package.metadata.requires-dev]
199
+ dev = [
200
+ { name = "pytest", specifier = ">=8" },
201
+ { name = "ruff", specifier = ">=0.8" },
202
+ ]
@@ -0,0 +1,5 @@
1
+ """xdfetch: fetch crossword puzzles as their outlets serve them, and convert them to .xd."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ USER_AGENT = f"xdfetch/{__version__} (+https://github.com/century-arcade/xdfetch)"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Adapters: one per transport (a plain URL, an embed's API, ...).
2
+
3
+ An adapter answers two questions for an outlet and a date: where is the file, and what
4
+ did the host send. It returns the bytes as served; it never converts.
5
+ """
@@ -0,0 +1,51 @@
1
+ """xdfetch command line.
2
+
3
+ list the outlets xdfetch knows
4
+ get fetch one outlet's puzzle and write it as .xd (not written yet)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from . import __version__, registry
13
+
14
+
15
+ def cmd_list(args: argparse.Namespace) -> int:
16
+ outlets = registry.load()
17
+ for o in sorted(outlets.values(), key=lambda o: o.id):
18
+ print(f"{o.id}\t{o.days}\t{o.homepage}")
19
+ return 0
20
+
21
+
22
+ def cmd_get(args: argparse.Namespace) -> int:
23
+ print("xdfetch get: not written yet", file=sys.stderr)
24
+ return 2
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ p = argparse.ArgumentParser(
29
+ prog="xdfetch", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
30
+ )
31
+ p.add_argument("--version", action="version", version=f"xdfetch {__version__}")
32
+ sub = p.add_subparsers(dest="command", required=True)
33
+
34
+ sp = sub.add_parser("list", help="the outlets xdfetch knows")
35
+ sp.set_defaults(func=cmd_list)
36
+
37
+ sp = sub.add_parser("get", help="fetch one outlet's puzzle and write it as .xd")
38
+ sp.add_argument("outlet")
39
+ sp.add_argument("--date", help="YYYY-MM-DD (default: the outlet's latest)")
40
+ sp.set_defaults(func=cmd_get)
41
+
42
+ return p
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ args = build_parser().parse_args(argv)
47
+ return args.func(args)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Converters: one per format, chosen by sniffing the bytes, never by file extension.
2
+
3
+ A converter takes the bytes an outlet served and builds a Puzzle. Whatever the source has
4
+ that .xd can't carry is listed on the Puzzle, not dropped silently.
5
+ """
@@ -0,0 +1 @@
1
+ id adapter params days tz homepage notes
@@ -0,0 +1,41 @@
1
+ """The outlet registry: outlets.tsv, one row per outlet.
2
+
3
+ Columns:
4
+ id the outlet's id, following the xd corpus's publication ids
5
+ adapter which adapter fetches it (see xdfetch.adapters)
6
+ params the adapter's parameters, as space-separated key=value
7
+ days publication days: daily, mon-sat, sun, ...
8
+ tz the outlet's time zone, for "today"
9
+ homepage the outlet's own page for its puzzles
10
+ notes anything worth knowing
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import csv
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ OUTLETS_TSV = Path(__file__).with_name("outlets.tsv")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Outlet:
24
+ id: str
25
+ adapter: str
26
+ params: str = ""
27
+ days: str = "daily"
28
+ tz: str = "America/New_York"
29
+ homepage: str = ""
30
+ notes: str = ""
31
+
32
+
33
+ def load(path: Path = OUTLETS_TSV) -> dict[str, Outlet]:
34
+ """Read a registry file. Later rows with the same id replace earlier ones."""
35
+ outlets: dict[str, Outlet] = {}
36
+ with path.open(encoding="utf-8", newline="") as f:
37
+ for row in csv.DictReader(f, delimiter="\t"):
38
+ fields = {k: (v or "") for k, v in row.items() if k in Outlet.__dataclass_fields__}
39
+ outlet = Outlet(**fields)
40
+ outlets[outlet.id] = outlet
41
+ return outlets