Compare commits

..

2 commits

Author SHA1 Message Date
Tiara Rodney
a4e215c69c
feat(http/parser): extend query selector 2025-12-31 15:47:05 +01:00
Tiara Rodney
db72017810
feature(http): init parser 2025-12-31 14:41:55 +01:00
28 changed files with 3388 additions and 3281 deletions

View file

@ -1,122 +0,0 @@
# Development
> All changes MUST follow the vendor/tiara-gitflow-spec.git and no work MUST be
> started without a TODO issue.
## Prerequisites
- Python 3.9+
- [Pipenv](https://pipenv.pypa.io/)
- [tox](https://tox.wiki/) (installed via Pipenv dev dependencies)
- Node.js (for the `@byteb4rb1e/mime-todo` issue tracker CLI)
## Setup
Iniitialize Git submodules:
```bash
git submodule update --init --remote --recursive
```
Install dependencies (includes the package in editable mode):
```bash
pipenv install --dev
```
## Tooling
### Package
The project is packaged as `byteb4rb1e.utils` under a namespace package
layout (`src/byteb4rb1e/utils/`). It is installed in editable mode via
Pipenv.
Build a distribution:
```bash
pipenv run dist
```
### Testing
Tests are managed by tox. Test environments are defined in `tox.ini`:
```bash
# run all test suites
tox
# run specific environments
tox -e unit-py313
tox -e lint
tox -e format
```
| Environment | Purpose |
|---|---|
| `unit-py3{9-13}` | Unit tests |
| `smoke-py3{9-13}` | Smoke tests |
| `integration-py3{9-13}` | Integration tests |
| `lint` | Type checking (mypy) |
| `format` | Code style (autopep8) |
| `audit` | Dependency audit (pip-audit) |
### Issue tracker
Issues are tracked in the `TODO` file using the
[MIME TODO](https://specs.code.tiararodney.com/mime-todo/) format. Use the
`@byteb4rb1e/mime-todo` CLI to interact with it:
```bash
# list issues
npx @byteb4rb1e/mime-todo list
# show a specific issue
npx @byteb4rb1e/mime-todo show 3
# create an issue
npx @byteb4rb1e/mime-todo create --type feature --title "Title" --plan "Description" --module homeostat
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full issue lifecycle.
### Publishing
Build wheel and source distributions:
```sh
pipenv run sdist
```
Configure publishing options:
`~/.pypirc`
```
[distutils]
index-servers =
tiararodney
[tiararodney]
repository: https://pypi.code.tiararodney.com/root/byteb4rb1e/
username: <username>
password: <password>
```
Publish to pypi.code.tiararodney.com:
```sh
pipenv run sdist:publish:tiarardoney
```
## Project layout
```
src/byteb4rb1e/utils/ # package source
tests/ # test suites (unit/, smoke/, integration/)
vendor/ # vendored specs
dist/ # sdist and wheel build output
DEVELOPMENT.md # this file
TODO # issue tracker (MIME TODO format)
```

32
Makefile Normal file
View file

@ -0,0 +1,32 @@
.PHONY: chore configure
chore: configure Pipfile.lock requirements-dev.txt
Pipfile.lock: .venv Pipfile
.venv/bin/pipenv lock
requirements-dev.txt: .venv Pipfile.lock
.venv/bin/pipenv requirements --dev-only > requirements-dev.txt
configure: configure.ac
autoconf
.venv: requirements-dev.txt
python3 -m venv .venv
.venv/bin/python3 -m pip install --upgrade pip
.venv/bin/pip install -r requirements-dev.txt
test-reports: test-reports/unit test-reports/static test-reports/integration
test-reports/unit:
python3 -m pipenv run -v test-unit
test-reports/integration:
python3 -m pipenv run -v test-integration
test-reports/static:
python3 -m pipenv run -v test-static
build: .venv/bin/pipenv
.venv/bin/pipenv run build

20
Pipfile
View file

@ -7,22 +7,14 @@ name = "pypi"
setuptools-scm = "~=8.2.0" setuptools-scm = "~=8.2.0"
build = "*" build = "*"
pipenv = "*" pipenv = "*"
byteb4rb1e-utils = { editable = true, path = '.'}
tox = "*" tox = "*"
twine = "*"
pypi-attestations = "*"
autopep8 = "*"
[requires] [requires]
python_version = "3" python_version = "3.11"
[scripts] [scripts]
"dist" = "python3 -m build" "build" = "python3 -m build"
"dist:attestations" = "python3 -m pypi_attestations sign dist/*" "test-static" = "tox run -m static"
"dist:publish:tiararodney" = "python3 -m twine upload --sign --repository tiararodney dist/*" "test-unit" = "tox run -m unit"
"test" = "tox" "test-integration" = "tox run -m integration"
"test:static" = "tox run -m static"
"test:unit" = "tox run -m unit"
"test:integration" = "tox run -m integration"
[packages]
"byteb4rb1e.utils" = {file = ".", editable = true}

890
Pipfile.lock generated
View file

@ -1,11 +1,11 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "7bf1e5e3285cb7ead9e247720d2abc340a64c17d42127e41745bff3309521b41" "sha256": "cb7c8c0a12f574d2bc30ffe38e79ba18ee29424cb1fb1cdce8373f89d56f3e1c"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
"python_version": "3" "python_version": "3.11"
}, },
"sources": [ "sources": [
{ {
@ -15,279 +15,44 @@
} }
] ]
}, },
"default": { "default": {},
"byteb4rb1e.utils": {
"editable": true,
"file": "."
}
},
"develop": { "develop": {
"annotated-types": {
"hashes": [
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
],
"markers": "python_version >= '3.8'",
"version": "==0.7.0"
},
"autopep8": {
"hashes": [
"sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758",
"sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.3.2"
},
"build": { "build": {
"hashes": [ "hashes": [
"sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5",
"sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936" "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.8'",
"version": "==1.4.0" "version": "==1.2.2.post1"
},
"byteb4rb1e-utils": {
"editable": true,
"path": "."
}, },
"cachetools": { "cachetools": {
"hashes": [ "hashes": [
"sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", "sha256:1c7bb3cf9193deaf3508b7c5f2a79986c13ea38965c5adcff1f84519cf39163e",
"sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114" "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.9'",
"version": "==7.0.5" "version": "==6.1.0"
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2026.2.25" "version": "==2025.6.15"
}, },
"cffi": { "chardet": {
"hashes": [ "hashes": [
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7",
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
},
"charset-normalizer": {
"hashes": [
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.4.6" "version": "==5.2.0"
}, },
"colorama": { "colorama": {
"hashes": [ "hashes": [
@ -297,237 +62,45 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'",
"version": "==0.4.6" "version": "==0.4.6"
}, },
"cryptography": {
"hashes": [
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.5"
},
"distlib": { "distlib": {
"hashes": [ "hashes": [
"sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87",
"sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d" "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"
], ],
"version": "==0.4.0" "version": "==0.3.9"
},
"dnspython": {
"hashes": [
"sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
"sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"
],
"markers": "python_version >= '3.10'",
"version": "==2.8.0"
},
"docutils": {
"hashes": [
"sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968",
"sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de"
],
"markers": "python_version >= '3.9'",
"version": "==0.22.4"
},
"email-validator": {
"hashes": [
"sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4",
"sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"
],
"markers": "python_version >= '3.8'",
"version": "==2.3.0"
}, },
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2",
"sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70" "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"
],
"markers": "python_version >= '3.10'",
"version": "==3.25.2"
},
"id": {
"hashes": [
"sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069",
"sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.6.1" "version": "==3.18.0"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"jaraco.classes": {
"hashes": [
"sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd",
"sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"
],
"markers": "python_version >= '3.8'",
"version": "==3.4.0"
},
"jaraco.context": {
"hashes": [
"sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535",
"sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"
],
"markers": "python_version >= '3.10'",
"version": "==6.1.2"
},
"jaraco.functools": {
"hashes": [
"sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176",
"sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"
],
"markers": "python_version >= '3.9'",
"version": "==4.4.0"
},
"jeepney": {
"hashes": [
"sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683",
"sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"
],
"markers": "python_version >= '3.7'",
"version": "==0.9.0"
},
"keyring": {
"hashes": [
"sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f",
"sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"
],
"markers": "python_version >= '3.9'",
"version": "==25.7.0"
},
"markdown-it-py": {
"hashes": [
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147",
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"
],
"markers": "python_version >= '3.10'",
"version": "==4.0.0"
},
"mdurl": {
"hashes": [
"sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
"sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
],
"markers": "python_version >= '3.7'",
"version": "==0.1.2"
},
"more-itertools": {
"hashes": [
"sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b",
"sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"
],
"markers": "python_version >= '3.9'",
"version": "==10.8.0"
},
"nh3": {
"hashes": [
"sha256:0d5eb734a78ac364af1797fef718340a373f626a9ff6b4fb0b4badf7927e7b81",
"sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee",
"sha256:1ef87f8e916321a88b45f2d597f29bd56e560ed4568a50f0f1305afab86b7189",
"sha256:21a63ccb18ddad3f784bb775955839b8b80e347e597726f01e43ca1abcc5c808",
"sha256:21b058cd20d9f0919421a820a2843fdb5e1749c0bf57a6247ab8f4ba6723c9fc",
"sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489",
"sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0",
"sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691",
"sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d",
"sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9",
"sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30",
"sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181",
"sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7",
"sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2",
"sha256:92a958e6f6d0100e025a5686aafd67e3c98eac67495728f8bb64fbeb3e474493",
"sha256:9ed40cf8449a59a03aa465114fedce1ff7ac52561688811d047917cc878b19ca",
"sha256:a446eae598987f49ee97ac2f18eafcce4e62e7574bd1eb23782e4702e54e217d",
"sha256:b50c3770299fb2a7c1113751501e8878d525d15160a4c05194d7fe62b758aad8",
"sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462",
"sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2",
"sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9",
"sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37",
"sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297",
"sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77",
"sha256:f4400a73c2a62859e769f9d36d1b5a7a5c65c4179d1dddd2f6f3095b2db0cbfc",
"sha256:f508ddd4e2433fdcb78c790fc2d24e3a349ba775e5fa904af89891321d4844a3",
"sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b"
],
"markers": "python_version >= '3.8'",
"version": "==0.3.3"
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==26.0" "version": "==25.0"
}, },
"pipenv": { "pipenv": {
"hashes": [ "hashes": [
"sha256:cd2858095181578ec17451f3ff02b8f74eb9038013ddbbc54228c5f0611fa3da", "sha256:87370bedcf0ff66d226af07ca341ae94afcc08fed90d57ad9fea9ffd44ced4d3",
"sha256:ddba48a3f9a27e6330b391180ba078354d4d8de480bbe49e7432d6c8ead5bbd7" "sha256:f0a67aa928824e61003d52acea72a94b180800019f03d38a311966f6330bc8d1"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.9'",
"version": "==2026.2.1" "version": "==2025.0.3"
}, },
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc",
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.9'",
"version": "==4.9.4" "version": "==4.3.8"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
@ -537,208 +110,13 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.6.0" "version": "==1.6.0"
}, },
"pyasn1": {
"hashes": [
"sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf",
"sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"
],
"markers": "python_version >= '3.8'",
"version": "==0.6.3"
},
"pycodestyle": {
"hashes": [
"sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783",
"sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"
],
"markers": "python_version >= '3.9'",
"version": "==2.14.0"
},
"pycparser": {
"hashes": [
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
],
"markers": "python_version >= '3.10'",
"version": "==3.0"
},
"pydantic": {
"extras": [
"email"
],
"hashes": [
"sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49",
"sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"
],
"markers": "python_version >= '3.9'",
"version": "==2.12.5"
},
"pydantic-core": {
"hashes": [
"sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90",
"sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740",
"sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504",
"sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84",
"sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33",
"sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c",
"sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0",
"sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e",
"sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0",
"sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a",
"sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34",
"sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2",
"sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3",
"sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815",
"sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14",
"sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba",
"sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375",
"sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf",
"sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963",
"sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1",
"sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808",
"sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553",
"sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1",
"sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2",
"sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5",
"sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470",
"sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2",
"sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b",
"sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660",
"sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c",
"sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093",
"sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5",
"sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594",
"sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008",
"sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a",
"sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a",
"sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd",
"sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284",
"sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586",
"sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869",
"sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294",
"sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f",
"sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66",
"sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51",
"sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc",
"sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97",
"sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a",
"sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d",
"sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9",
"sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c",
"sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07",
"sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36",
"sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e",
"sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05",
"sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e",
"sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941",
"sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3",
"sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612",
"sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3",
"sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b",
"sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe",
"sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146",
"sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11",
"sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60",
"sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd",
"sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b",
"sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c",
"sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a",
"sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460",
"sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1",
"sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf",
"sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf",
"sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858",
"sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2",
"sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9",
"sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2",
"sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3",
"sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6",
"sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770",
"sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d",
"sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc",
"sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23",
"sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26",
"sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa",
"sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8",
"sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d",
"sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3",
"sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d",
"sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034",
"sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9",
"sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1",
"sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56",
"sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b",
"sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c",
"sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a",
"sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e",
"sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9",
"sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5",
"sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a",
"sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556",
"sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e",
"sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49",
"sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2",
"sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9",
"sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b",
"sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc",
"sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb",
"sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0",
"sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8",
"sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82",
"sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69",
"sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b",
"sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c",
"sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75",
"sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5",
"sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f",
"sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad",
"sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b",
"sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7",
"sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425",
"sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"
],
"markers": "python_version >= '3.9'",
"version": "==2.41.5"
},
"pygments": {
"hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
],
"markers": "python_version >= '3.8'",
"version": "==2.19.2"
},
"pyjwt": {
"hashes": [
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
],
"markers": "python_version >= '3.9'",
"version": "==2.12.1"
},
"pyopenssl": {
"hashes": [
"sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81",
"sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"
],
"markers": "python_version >= '3.8'",
"version": "==26.0.0"
},
"pypi-attestations": {
"hashes": [
"sha256:278a28d741b57d62973c00d453ec9b9bb30456464d69296c6780474cd0bf098e",
"sha256:2daf3ec46ff4c7123184ec892852b4d4599b78128f01f742a44406a73200c5df"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.0.29"
},
"pyproject-api": { "pyproject-api": {
"hashes": [ "hashes": [
"sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335",
"sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09" "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.9'",
"version": "==1.10.0" "version": "==1.9.1"
}, },
"pyproject-hooks": { "pyproject-hooks": {
"hashes": [ "hashes": [
@ -748,104 +126,13 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==1.2.0" "version": "==1.2.0"
}, },
"python-discovery": {
"hashes": [
"sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a",
"sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1"
],
"markers": "python_version >= '3.8'",
"version": "==1.2.0"
},
"readme-renderer": {
"hashes": [
"sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151",
"sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1"
],
"markers": "python_version >= '3.9'",
"version": "==44.0"
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
},
"requests-toolbelt": {
"hashes": [
"sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6",
"sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.0"
},
"rfc3161-client": {
"hashes": [
"sha256:078e4bbf0770ddc472e2ca96cf1e23efd0c313e6682b4c2c9765e1fdf09f55a3",
"sha256:09c47582ecea2ca4a3debf8a1eda775cc3d5ae1379da40272cc065d32e639a7a",
"sha256:3106f3361a5a36789f43d2700e5678c847a9d3460a23f455f4c20cd39314c557",
"sha256:31b6ee79f15b93d90952efd0395bb3f5ebf07941469c5c6eb32f9b64312cda6e",
"sha256:61c04b4953453e5c26a1949c20adac415b65cd062dab0960574d6c36240222d2",
"sha256:8a54fdb2f9e64481272b89137a7b71403cf1d30f5505c2e0c15a47a1cc100264",
"sha256:8fb34470e867a29cc15dc4987ea14f19d3bd25c863e132b6f75dca583e2cc67e",
"sha256:9c53a6711bab0c3f77dc9cf1e2fd750da475ff7abbc40ffe0333d8c518a8a9c8",
"sha256:ae440461a310ae097417afe536d9d22fd71c95fbc9d21db3561b2707bed0aff0",
"sha256:d31d30e354d2349ae8483ce811ef61498a3780daf8622c0b79d8cd44d271b46b",
"sha256:d9ed8e597d0ee7387da1945e1583c4516b26f133770b3956e079606e2d90b69c",
"sha256:e904430e27e75a5a379fc4aac09bd60ba5f4b48054f0481b2fb417297e404047",
"sha256:f1a2e32e2a053455cee1ff9b325b88dbc7c66c8882dde60962add92f572df5c5"
],
"markers": "python_version >= '3.9'",
"version": "==1.0.5"
},
"rfc3986": {
"hashes": [
"sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd",
"sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"
],
"markers": "python_version >= '3.7'",
"version": "==2.0.0"
},
"rfc8785": {
"hashes": [
"sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48",
"sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da"
],
"markers": "python_version >= '3.8'",
"version": "==0.1.4"
},
"rich": {
"hashes": [
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
],
"markers": "python_full_version >= '3.8.0'",
"version": "==14.3.3"
},
"secretstorage": {
"hashes": [
"sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137",
"sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"
],
"markers": "python_version >= '3.10'",
"version": "==3.5.0"
},
"securesystemslib": {
"hashes": [
"sha256:2e5414bbdde33155a91805b295cbedc4ae3f12b48dccc63e1089093537f43c81",
"sha256:ca915f4b88209bb5450ac05426b859d74b7cd1421cafcf73b8dd3418a0b17486"
],
"markers": "python_version ~= '3.8'",
"version": "==1.3.1"
},
"setuptools": { "setuptools": {
"hashes": [ "hashes": [
"sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922",
"sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb" "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==82.0.1" "version": "==80.9.0"
}, },
"setuptools-scm": { "setuptools-scm": {
"hashes": [ "hashes": [
@ -856,95 +143,22 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==8.2.0" "version": "==8.2.0"
}, },
"sigstore": {
"hashes": [
"sha256:bdbb49a42fd5f0ea6765919adb42ccee7254c482330764d0842eec4e11ad78d7",
"sha256:ed2e0f50aae85148a8aa4fc0f57c298927fce430ad1f988f38611ce90c85829f"
],
"markers": "python_version >= '3.10'",
"version": "==4.2.0"
},
"sigstore-models": {
"hashes": [
"sha256:5201a68f4d7d0f8bec1e2f4378eb646b084c52609a4e31db8c385095fff68b2e",
"sha256:c766c09470c2a7e8a4a333c893f07e2001c56a3ff1757b1a246119f53169a849"
],
"markers": "python_version >= '3.10'",
"version": "==0.0.6"
},
"sigstore-rekor-types": {
"hashes": [
"sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5",
"sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78"
],
"markers": "python_version >= '3.8'",
"version": "==0.0.18"
},
"tomli-w": {
"hashes": [
"sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90",
"sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"
],
"markers": "python_version >= '3.9'",
"version": "==1.2.0"
},
"tox": { "tox": {
"hashes": [ "hashes": [
"sha256:5e788a512bfe6f7447e0c8d7c1b666eb2e56e5e676c65717490423bec37d1a07", "sha256:2b8a7fb986b82aa2c830c0615082a490d134e0626dbc9189986da46a313c4f20",
"sha256:c745641de6cc4f19d066bd9f98c1c25f7affb005b381b7f3694a1f142ea0946b" "sha256:b97d5ecc0c0d5755bcc5348387fef793e1bfa68eb33746412f4c60881d7f5f57"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.50.3"
},
"tuf": {
"hashes": [
"sha256:458f663a233d95cc76dde0e1a3d01796516a05ce2781fefafebe037f7729601a",
"sha256:9eed0f7888c5fff45dc62164ff243a05d47fb8a3208035eb268974287e0aee8d"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.0"
},
"twine": {
"hashes": [
"sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8",
"sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==6.2.0" "version": "==4.27.0"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"typing-inspection": {
"hashes": [
"sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
"sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"
],
"markers": "python_version >= '3.9'",
"version": "==0.4.2"
},
"urllib3": {
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
}, },
"virtualenv": { "virtualenv": {
"hashes": [ "hashes": [
"sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11",
"sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f" "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==21.2.0" "version": "==20.31.2"
} }
} }
} }

218
TODO
View file

@ -1,52 +1,91 @@
--ISSUE # TODO List for esm-logging
Content-Type: application/sprints
Sprints: This is a poor-man's issue tracker. I am not primarily a GitHub user so don't
want to commit to their issue tracking feature, but my primary SVC service
provider (Bitbucket) only offers paid integration into their issue tracker
(Jira). I don't have the time (and patience) at the moment to analyze the best
approach, so this file will have to suffice.
It's a very simple concept: Track any issues (features, bugfixes, hotfixes) in
here, assign a sequential number to it and use that number when branching.
I will try to develop a format so that I can parse the file later on, should I
decide to migrate to a real issue tracker. It's probably going to be Bugzilla,
but for that my html-theme-ref project needs to stabilize first.
## Format Specification
The file uses Markdown conventions for formatting headers and other text block
entitities, but SHOULD NOT be considered a Markdown file. That's why it has no
definitive file extension.
Each issue entry follows a structured format for easier parsing and future
migration. Issues MUST be **appended** to this file and never moved, to
preserve Git diffing.
### Issue Format
```
ID: [ISSUE-NUMBER]
Type: [feature/bugfix/hotfix]
Title: [Short title]
Status: [open/in-progress/done/hold/cancelled]
Priority: [low/medium/high]
Created: [YYYY-MM-DD]
Description: [Detailed explanation]
---
```
- ISSUE-NUMBERs must be sequential
- truncation of description must be indentended so that every line starts at the
same column
- issues must be started with two LF
- issues must be terminated with two LF, then `---`
- issues may have a free-text field (epilog), which must be started with two LF.
## Issues
--ISSUE
Content-Type: application/issue
ID: 1 ID: 1
Type: feature Type: feature
Title: implement KMP algorithm for string searching Title: implement KMP algorithm for string searching
Status: hold Status: hold
Priority: high Priority: high
Created: 2025-05-03 Created: 2025-05-03
Relationships:
Description: Implement the Knuth-Morris-Pratt algorithm for string searching. Description: Implement the Knuth-Morris-Pratt algorithm for string searching.
I require this for matching RFC 9112 boundaries of entities against I require this for matching RFC 9112 boundaries of entities against
a circular buffer. a circular buffer.
--ISSUE ---
Content-Type: application/issue
ID: 2 ID: 2
Type: feature Type: feature
Title: implement circular buffer Title: implement circular buffer
Status: done Status: done
Priority: high Priority: high
Created: 2025-05-04 Created: 2025-05-04
Relationships:
Description: implement a simple circular buffer Description: implement a simple circular buffer
--ISSUE ---
Content-Type: application/issue
ID: 3 ID: 3
Type: bugfix Type: bugfix
Title: move unit tests to subdirectory Title: move unit tests to subdirectory
Status: done Status: done
Priority: high Priority: high
Created: 2025-05-04 Created: 2025-05-04
Relationships:
Description: move the unit test suites to a unit/ subdirectory so that Description: move the unit test suites to a unit/ subdirectory so that
integration tests and benchmarks can be cleanly separated integration tests and benchmarks can be cleanly separated
--ISSUE ---
Content-Type: application/issue
ID: 4 ID: 4
Type: feature Type: feature
Title: implement Rabin-Karp rolling hash algorithm Title: implement Rabin-Karp rolling hash algorithm
Status: done Status: done
Priority: high Priority: high
Created: 2025-05-05 Created: 2025-05-05
Relationships:
Description: After testing a couple of string search algorithms, I've ditched Description: After testing a couple of string search algorithms, I've ditched
the idea of using KMP as my use-case gives no advantage compared to the idea of using KMP as my use-case gives no advantage compared to
naive searching. In addition I've came upon the challenge that many naive searching. In addition I've came upon the challenge that many
@ -58,242 +97,147 @@ Description: After testing a couple of string search algorithms, I've ditched
need an implementation of the original Rabin-Karp rolling hash need an implementation of the original Rabin-Karp rolling hash
algorithm algorithm
--ISSUE ---
Content-Type: application/issue
ID: 5 ID: 5
Type: feature Type: feature
Title: implement chunked rolling hash algorithm Title: implement chunked rolling hash algorithm
Status: in-progress Status: in-progress
Priority: high Priority: high
Created: 2025-05-05 Created: 2025-05-05
Relationships:
Description: Implement my custom algorithm for doing rolling hash string search Description: Implement my custom algorithm for doing rolling hash string search
against a fixed length ring buffer against a fixed length ring buffer
--ISSUE ---
Content-Type: application/issue
ID: 6 ID: 6
Type: feature Type: feature
Title: implement importlib.resources handler for urllib Title: implement importlib.resources handler for urllib
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-20 Created: 2025-06-20
Relationships:
Description: A handler that can be registered with an urllib.request Description: A handler that can be registered with an urllib.request
OpenerDirector to open importlib.resources package files. OpenerDirector to open importlib.resources package files.
--ISSUE ---
Content-Type: application/issue
ID: 7 ID: 7
Type: feature Type: feature
Title: setup advanced testing environment Title: setup advanced testing environment
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-20 Created: 2025-06-20
Relationships:
Description: copy the testing environment setup from Description: copy the testing environment setup from
byteb4rb1e.sphinxcontrib.ext byteb4rb1e.sphinxcontrib.ext
--ISSUE ---
Content-Type: application/issue
ID: 8 ID: 8
Type: bugfix Type: bugfix
Title: rename package Title: rename package
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-20 Created: 2025-06-20
Relationships:
Description: use dot namespaces to make the package a little more elegant Description: use dot namespaces to make the package a little more elegant
--ISSUE ---
Content-Type: application/issue
ID: 9 ID: 9
Type: bugfix Type: bugfix
Title: fix LICENSE reference Title: fix LICENSE reference
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-20 Created: 2025-06-20
Relationships:
Description: license specification is no longer a trove classifier in Description: license specification is no longer a trove classifier in
pyproject.toml, hence the reference to LICENSE must be changed pyproject.toml, hence the reference to LICENSE must be changed
--ISSUE ---
Content-Type: application/issue
ID: 10 ID: 10
Type: feature Type: feature
Title: pytest current test context fixtures Title: pytest current test context fixtures
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-20 Created: 2025-06-20
Relationships:
Description: add fixtures for doing things in relation to the active testing Description: add fixtures for doing things in relation to the active testing
context context
--ISSUE ---
Content-Type: application/issue
ID: 11 ID: 11
Type: bugfix Type: bugfix
Title: move testing utils out of utils Title: move testing utils out of utils
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-20 Created: 2025-06-20
Relationships:
Description: to shorten the namespace and also indicate that testing utilities Description: to shorten the namespace and also indicate that testing utilities
are different from regular utilities are different from regular utilities
--ISSUE ---
Content-Type: application/issue
ID: 12 ID: 12
Type: feature Type: feature
Title: simplify testing.fixtures.mock_pkg Title: simplify testing.fixtures.mock_pkg
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-21 Created: 2025-06-21
Relationships:
Description: Only bootstrap a package mock with the minimum requirements for a Description: Only bootstrap a package mock with the minimum requirements for a
Python module and let the consumer handle the directory layout. Python module and let the consumer handle the directory layout.
--ISSUE ---
Content-Type: application/issue
ID: 13 ID: 13
Type: bugfix Type: bugfix
Title: fix unit tests for urllib PkgHandler Title: fix unit tests for urllib PkgHandler
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-21 Created: 2025-06-21
Relationships:
Description: change of issue 12 wasn't properly reflected in urllib PkgHandler Description: change of issue 12 wasn't properly reflected in urllib PkgHandler
unit tests unit tests
--ISSUE ---
Content-Type: application/issue
ID: 14 ID: 14
Type: feature Type: feature
Title: add compression support for urllib PkgHandler Title: add compression support for urllib PkgHandler
Status: done Status: done
Priority: high Priority: high
Created: 2025-06-21 Created: 2025-06-21
Relationships:
Description: with a proper content-type of the PkgHandler addinfourl object, a Description: with a proper content-type of the PkgHandler addinfourl object, a
consumer can determine whether the file is compressed or not. consumer can determine whether the file is compressed or not.
--ISSUE ---
Content-Type: application/issue
ID: 15 ID: 15
Type: bugfix Type: bugfix
Title: modularize module containers Title: modularize module containers
Status: open Status: open
Priority: high Priority: high
Created: 2025-06-28 Created: 2025-06-28
Relationships:
Description: Even though importlib can find submodules through traversing paths Description: Even though importlib can find submodules through traversing paths
instead of relying on __init__.py for every ancestor module, this instead of relying on __init__.py for every ancestor module, this
is not supported by some modules like sphinx.ext.autosummary is not supported by some modules like sphinx.ext.autosummary
--ISSUE ---
Content-Type: application/issue
ID: 16 ID: 16
Type: feature Type: feature
Title: SQL-aware dataclass Title: SQL-aware dataclass
Status: in-progress Status: in-progress
Priority: low Priority: low
Created: 2025-12-31 Created: 2025-12-31
Relationships:
Description: A dataclass that transparently maps onto an SQL datastore, with Description: A dataclass that transparently maps onto an SQL datastore, with
command generation for syncing data between data class and store command generation for syncing data between data class and store
--ISSUE ---
Content-Type: application/issue
ID: 17 ID: 17
Type: feature Type: feature
Title: recursive-descent HTML (DOM) parser Title: recursive-descent HTML (DOM) parser
Status: in-progress Status: in-progress
Priority: high Priority: high
Created: 2025-12-31 Created: 2025-12-31
Relationships:
Description: Extend the built-in event-driven parser to be modeled after DOM Description: Extend the built-in event-driven parser to be modeled after DOM
recursive-descent HTML parser recursive-descent HTML parser
--ISSUE ---
Content-Type: application/issue
ID: 18
Type: feature
Title: implement saas wrapper for Forgejo
Status: done
Priority: medium
Created: 2026-06-06
Relationships:
Description: Add a new sub-package byteb4rb1e.utils.saas.forgejo, supporting the
same/similar operations as the Bitbucket wrapper
(byteb4rb1e.utils.saas.bitbucket) against the Forgejo REST API:
token-based authentication headers, repository existence checks,
repository creation within an owner/organization, and clone URL
construction. Implement as a thin layer over
byteb4rb1e.utils.http.client, consistent with the existing
Bitbucket and GitHub modules.
Unlike Bitbucket (one global SaaS instance, hence the hardcoded
api.bitbucket.org), Forgejo is self-hosted (e.g.
git.code.tiararodney.com). The wrapper MUST take a host/instance
URL parameter (or read one from config) rather than baking any
specific instance in. This is the biggest API-surface difference
from the bitbucket module.
Bitbucket's clone_url constructs SSH only. Forgejo's repository
API returns both clone_url (HTTPS) and ssh_url, and HTTPS is
needed in CI (no SSH host keys on the Woodpecker runner). The
wrapper SHOULD expose both, either as ssh_clone_url and
https_clone_url, or a single clone_url(..., scheme="ssh"|"https").
--ISSUE
Content-Type: application/issue
ID: 19
Type: feature
Title: config framework with CLI integration
Status: done
Priority: medium
Created: 2026-06-06
Relationships:
Description: Add byteb4rb1e.utils.config: INI-backed config dataclasses where a
dataclass is the single source of truth for settings, with three
layers (field defaults, INI file sections, CLI overrides). Includes
INI loading/writing (load_ini, ensure_ini, ensure_ini_multi,
format_section), per-flag CLI integration (add_config_arguments,
apply_cli_overrides), dotted-path overrides via a unified --config
KEY=VALUE flag (apply_overrides, format_help), and the companion
argparse KeyValueAction (byteb4rb1e.utils.argparse.actions) that
accumulates KEY=VALUE pairs into a dict.
--ISSUE
Content-Type: application/issue
ID: 20
Type: feature
Title: cookie-persisting HTTP session client
Status: done
Priority: medium
Created: 2026-06-06
Relationships:
Description: Extend byteb4rb1e.utils.http.client with an HttpSession class that
persists cookies across requests via http.cookiejar (suitable for
login followed by cookie-authenticated page fetches), supporting
GET with query params, form-encoded POST, default/per-request
header merging, and HTTPError-to-response conversion. Also refactor
HttpResponse into a frozen dataclass with text as a derived
property.
--ISSUE
Content-Type: application/issue
ID: 21
Type: feature
Title: relax host restriction in vcs.git parse_base_url and parse_repo_name
Status: done
Priority: high
Created: 2026-06-06
Relationships:
Description: Both byteb4rb1e.utils.vcs.git.parse_base_url and parse_repo_name
currently hard-reject any URL whose host is not exactly
'bitbucket.org' with a ValueError. The check predates the
multi-SaaS world (it dates back to when bootstrapping required the
Bitbucket API). With the new forgejo saas wrapper (#18) in place,
downstream consumers (specifically sphinxcontrib.h5p.utils.pkg
#105) now feed Forgejo-shaped URLs like
'git@git.code.tiararodney.com:h5p-mirror/foo.git' through these
helpers and hit the restriction.

2663
configure vendored Normal file

File diff suppressed because it is too large Load diff

27
configure.ac Normal file
View file

@ -0,0 +1,27 @@
AC_INIT
AC_CHECK_PROGS([MAKE], [make], [no])
AS_IF([test "$MAKE" == "no"],
[AC_MSG_NOTICE([without GNU Make, you have to inspect 'Makefile' and deduce build targets yourself.])])
AC_CHECK_PROGS([GIT], [git], [no])
AS_IF([test "$GIT" == "no"],
[AC_MSG_ERROR([install Git, before continuing.])])
AC_CHECK_PROGS([PYTHON3], [python3], [no])
AS_IF([test "$PYTHON3" == "no"],
[AC_MSG_ERROR([install Python 3, before continuing.])])
# required in Makefile to ensure proper path resolution during preprocessing
# realpath is not available on macOS
AC_CHECK_PROGS([REALPATH], [realpath], [no])
AS_IF([test "$REALPATH" == "no"],
[AC_MSG_ERROR([set a persistent alias for 'realpath', before continuing, e.g.
alias='python3 -c "import pathlib,sys;print(pathlib.Path(sys.argv[[1]]).resolve())"'"
])])
AC_MSG_NOTICE([initializing python3 venv...])
make .venv
AC_OUTPUT

View file

@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta"
name = "byteb4rb1e.utils" name = "byteb4rb1e.utils"
description = "personal utilities and helpers" description = "personal utilities and helpers"
authors = [ authors = [
{ name = "Tiara Rodney", email = "tiara.rodney@byteb4rb1e.me" } { name = "Tiara Rodney", email = "tiara.rodney@administratrix.de" }
] ]
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
@ -48,6 +48,7 @@ strict = true
max_line_length = 80 max_line_length = 80
aggressive = 3 aggressive = 3
recursive = true recursive = true
in-place = true
[tool.setuptools_scm] [tool.setuptools_scm]

25
requirements-dev.txt Normal file
View file

@ -0,0 +1,25 @@
-i https://pypi.org/simple
astroid==3.3.9; python_full_version >= '3.9.0'
autopep8==2.3.2; python_version >= '3.9'
build==1.2.2.post1; python_version >= '3.8'
-e .
certifi==2025.4.26; python_version >= '3.6'
colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'
dill==0.4.0; python_version >= '3.8'
distlib==0.3.9
filelock==3.18.0; python_version >= '3.9'
isort==6.0.1; python_full_version >= '3.9.0'
mccabe==0.7.0; python_version >= '3.6'
mypy==1.15.0; python_version >= '3.9'
mypy-extensions==1.1.0; python_version >= '3.8'
packaging==25.0; python_version >= '3.8'
pipenv==2025.0.2; python_version >= '3.9'
platformdirs==4.3.7; python_version >= '3.9'
pycodestyle==2.13.0; python_version >= '3.9'
pylint==3.3.6; python_full_version >= '3.9.0'
pyproject-hooks==1.2.0; python_version >= '3.7'
setuptools==80.3.0; python_version >= '3.9'
setuptools-scm==8.2.0; python_version >= '3.8'
tomlkit==0.13.2; python_version >= '3.8'
typing-extensions==4.13.2; python_version >= '3.8'
virtualenv==20.30.0; python_version >= '3.8'

View file

@ -1,7 +0,0 @@
"""Utilities for building composable CLIs from command dataclasses."""
from byteb4rb1e.utils.argparse.actions import KeyValueAction
from byteb4rb1e.utils.argparse.command import CLICommand
from byteb4rb1e.utils.argparse.dispatcher import CLI
__all__ = ["CLI", "CLICommand", "KeyValueAction"]

View file

@ -1,33 +0,0 @@
"""Custom argparse actions."""
from __future__ import annotations
import argparse
from typing import Any
class KeyValueAction(argparse.Action):
"""Argparse action that accumulates ``KEY=VALUE`` pairs into a dict.
Usage::
parser.add_argument("--config", action=KeyValueAction,
default={}, metavar="KEY=VALUE",
help="Set a config option (can be repeated)")
Then ``args.config`` is a ``dict[str, str]``.
"""
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Any,
option_string: str | None = None,
) -> None:
d = getattr(namespace, self.dest, None) or {}
if "=" not in values:
parser.error(f"Invalid format: {values!r} (expected KEY=VALUE)")
key, _, value = values.partition("=")
d[key.strip()] = value.strip()
setattr(namespace, self.dest, d)

View file

@ -1,54 +0,0 @@
"""Base command dataclass for composable CLI trees."""
from __future__ import annotations
from argparse import ArgumentParser
from dataclasses import dataclass, fields
from typing import Any, ClassVar, Dict, List, Optional, Type
@dataclass
class CLICommand:
"""Base class for CLI commands.
Subclasses define their identity (name, help, description) as
dataclass fields. These are passed as kwargs to
``subparsers.add_parser()``.
Override ``add_arguments`` to register flags and positionals.
Override ``execute`` to implement the command's logic.
Nest subcommands by setting ``_subcommands`` as a class variable.
"""
name: str = ""
help: str = ""
description: str = ""
_subcommands: ClassVar[List[Type[Command]]] = []
def add_arguments(self, parser: ArgumentParser) -> None:
"""Add arguments to the parser. Override in subclasses."""
def execute(self, args: Any) -> int:
"""Run the command. Override in subclasses.
Returns an exit code (0 = success).
"""
return 0
def parser_kwargs(self) -> Dict[str, Any]:
"""Return the dataclass fields as kwargs for add_parser.
Excludes ``name`` (used as the positional parser name) and
any empty-string fields so argparse defaults apply.
"""
skip = {"name"}
kwargs = {}
for f in fields(self):
if f.name in skip or f.name.startswith("_"):
continue
val = getattr(self, f.name)
if val != "":
kwargs[f.name] = val
return kwargs

View file

@ -1,122 +0,0 @@
"""CLI dispatcher — builds parser trees from command dataclasses."""
from __future__ import annotations
import logging
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
from typing import Any, Dict, List, Optional, Type
from byteb4rb1e.utils.argparse.command import CLICommand
class CLI:
"""Composable CLI built from a tree of Command dataclasses.
Recursively bootstraps an argparse parser hierarchy and tracks
dest names so ``run()`` can dispatch to the correct leaf command
without dest chaining in the caller.
Usage::
cli = CLI(prog="repository", description="...")
cli.bootstrap([MirrorCommand, IndexCommand])
cli.run()
"""
def __init__(
self,
prog: Optional[str] = None,
description: str = "",
) -> None:
kwargs = {} # type: Dict[str, Any]
if prog:
kwargs["prog"] = prog
if description:
kwargs["description"] = description
kwargs.setdefault(
"formatter_class", ArgumentDefaultsHelpFormatter,
)
self.parser = ArgumentParser(**kwargs)
self._dests = [] # type: List[str]
self._commands = {} # type: Dict[str, Command]
def add_arguments(self, parser: ArgumentParser) -> None:
"""Add global arguments to the root parser."""
parser.add_argument(
"-v", "--verbose", action="count", default=0,
help="Increase verbosity (-v for INFO, -vv for DEBUG)",
)
def bootstrap(
self,
commands: List[Type[Command]],
) -> None:
"""Build the parser tree from a list of top-level commands."""
self.add_arguments(self.parser)
dest = "command"
self._dests.append(dest)
sub = self.parser.add_subparsers(dest=dest)
for cmd_cls in commands:
self._add(sub, cmd_cls, prefix="")
def _add(
self,
subparsers: Any,
cmd_cls: Type[Command],
prefix: str,
) -> None:
"""Recursively add a command and its subcommands."""
cmd = cmd_cls()
parser = subparsers.add_parser(
cmd.name,
formatter_class=ArgumentDefaultsHelpFormatter,
**cmd.parser_kwargs(),
)
cmd.add_arguments(parser)
key = "%s.%s" % (prefix, cmd.name) if prefix else cmd.name
self._commands[key] = cmd
if cmd._subcommands:
dest = "%s_command" % cmd.name
self._dests.append(dest)
child_sub = parser.add_subparsers(dest=dest)
for sc_cls in cmd._subcommands:
self._add(child_sub, sc_cls, prefix=key)
def _resolve(self, args: Any) -> Optional[Command]:
"""Walk dest chain to find the leaf command."""
parts = [] # type: List[str]
for dest in self._dests:
val = getattr(args, dest, None)
if val is None:
continue
parts.append(val)
if not parts:
return None
key = ".".join(parts)
return self._commands.get(key)
@staticmethod
def _setup_logging(verbosity: int) -> None:
if verbosity >= 2:
level = logging.DEBUG
elif verbosity >= 1:
level = logging.INFO
else:
level = logging.WARNING
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()],
)
def run(self) -> None:
"""Parse args and dispatch to the leaf command."""
args = self.parser.parse_args()
self._setup_logging(getattr(args, "verbose", 0))
cmd = self._resolve(args)
if cmd is None:
self.parser.print_help()
raise SystemExit(1)
raise SystemExit(cmd.execute(args))

View file

@ -1,369 +0,0 @@
"""Config framework — INI-backed dataclasses with CLI integration.
A config dataclass is the single source of truth for settings. Values
come from three layers (later wins):
1. Dataclass field defaults
2. INI file sections
3. CLI overrides (via argparse flags or ``--config KEY=VALUE``)
Two CLI integration styles:
- ``add_config_arguments`` generates one ``--flag`` per field.
- ``apply_overrides`` accepts a ``dict[str, str]`` of dotted-path
overrides from a unified ``--config KEY=VALUE`` flag.
"""
import configparser
from argparse import ArgumentParser, Namespace
from dataclasses import MISSING, fields
from pathlib import Path
from typing import Any, Type, TypeVar, get_type_hints
T = TypeVar("T")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _parse_bool(value: str) -> bool:
"""Parse a boolean from INI/CLI string."""
return value.lower() in ("true", "yes", "1", "on")
_TYPE_MAP = {
int: int,
float: float,
str: str,
bool: _parse_bool,
}
def resolve_hints(cls: Type) -> dict[str, type]:
"""Resolve type hints for a dataclass, handling both evaluated
and string annotations.
:param cls: a dataclass class.
:returns: dict mapping field names to resolved types.
"""
try:
return get_type_hints(cls)
except Exception:
return {
f.name: f.type if isinstance(f.type, type)
else str
for f in fields(cls)
}
def _section_name(cls: Type, section: str | None = None) -> str:
"""Derive INI section name from class name if not provided."""
if section is not None:
return section
name = cls.__name__
if name.endswith("Config"):
name = name[: -len("Config")]
return name.lower()
# ---------------------------------------------------------------------------
# INI loading
# ---------------------------------------------------------------------------
def load_ini(
cls: Type[T],
path: Path,
section: str | None = None,
) -> T:
"""Load a config dataclass from an INI file.
If *section* is not given, the dataclass name (lowercased,
without trailing "Config") is used.
Unknown keys in the INI file raise ValueError. Missing keys
use the dataclass default.
"""
section = _section_name(cls, section)
parser = configparser.ConfigParser(
comment_prefixes=("#", ";"),
inline_comment_prefixes=("#", ";"),
)
parser.read(path)
if not parser.has_section(section):
return cls() # type: ignore[call-arg]
hints = resolve_hints(cls)
field_names = {f.name for f in fields(cls) if f.init}
kwargs: dict[str, Any] = {}
for key, raw_value in parser.items(section):
if key not in field_names:
raise ValueError(
f"Unknown config key '{key}' in"
f" [{section}]. Valid keys:"
f" {sorted(field_names)}"
)
field_type = hints.get(key, str)
coerce = _TYPE_MAP.get(field_type, field_type)
kwargs[key] = coerce(raw_value)
return cls(**kwargs) # type: ignore[call-arg]
# ---------------------------------------------------------------------------
# INI writing
# ---------------------------------------------------------------------------
def format_section(cls: Type, section: str | None = None) -> str:
"""Format a config dataclass as an INI section string.
Returns the section header and all fields with their defaults
as commented key-value pairs.
:param cls: a dataclass class.
:param section: section name (derived from class name if None).
:returns: INI section string.
"""
section = _section_name(cls, section)
hints = resolve_hints(cls)
lines = [f"[{section}]"]
for f in fields(cls):
if not f.init:
continue
field_type = hints.get(f.name, str)
type_name = getattr(field_type, "__name__", str(field_type))
if f.default is not MISSING:
default = f.default
elif f.default_factory is not MISSING: # type: ignore[arg-type]
default = f.default_factory() # type: ignore[misc]
else:
continue
lines.append(f"# {f.name} ({type_name})")
lines.append(f"{f.name} = {default}")
lines.append("")
return "\n".join(lines)
def ensure_ini(
cls: Type[T],
path: Path,
section: str | None = None,
) -> T:
"""Load config from INI, creating the file with defaults if
it does not exist.
On first run, writes a commented INI file with all fields and
their default values. On subsequent runs, reads the existing
file. Never writes back CLI overrides.
"""
section = _section_name(cls, section)
if not path.exists():
_write_default_ini(cls, path, section)
return load_ini(cls, path, section)
def ensure_ini_multi(
configs: list[tuple[Type, str | None]],
path: Path,
) -> None:
"""Create an INI file with multiple sections if it does not exist.
Each entry is a (dataclass_cls, section_name) tuple. If
section_name is None, it is derived from the class name.
Does not overwrite an existing file.
:param configs: list of (cls, section) tuples.
:param path: path to the INI file.
"""
if path.exists():
return
sections = [format_section(cls, section) for cls, section in configs]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(sections) + "\n")
def _write_default_ini(
cls: Type,
path: Path,
section: str,
) -> None:
"""Write an INI file with all fields as commented defaults."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(format_section(cls, section) + "\n")
# ---------------------------------------------------------------------------
# CLI: per-flag style (add_config_arguments / apply_cli_overrides)
# ---------------------------------------------------------------------------
def add_config_arguments(
cls: Type[T],
parser: ArgumentParser,
prefix: str = "",
) -> None:
"""Add CLI arguments for each field in a config dataclass.
Field names are converted to CLI flags: ``heart_rate_resolution``
becomes ``--heart-rate-resolution`` (or ``--<prefix>-heart-rate-resolution``
if a prefix is given).
"""
hints = resolve_hints(cls)
for f in fields(cls):
if not f.init:
continue
flag_name = f.name.replace("_", "-")
if prefix:
flag_name = f"{prefix}-{flag_name}"
field_type = hints.get(f.name, str)
kwargs: dict[str, Any] = {
"dest": f.name,
}
if field_type is bool:
kwargs["action"] = (
"store_false"
if f.default is True
else "store_true"
)
kwargs["default"] = None
else:
kwargs["type"] = _TYPE_MAP.get(
field_type, field_type
)
kwargs["default"] = None
kwargs["metavar"] = field_type.__name__.upper()
parser.add_argument(f"--{flag_name}", **kwargs)
def apply_cli_overrides(
config: T,
args: Namespace,
) -> T:
"""Apply CLI argument values to a config instance.
Only overrides fields that were explicitly set on the command
line (not None). Returns a new instance.
"""
overrides = {}
for f in fields(config): # type: ignore[arg-type]
if not f.init:
continue
cli_value = getattr(args, f.name, None)
if cli_value is not None:
overrides[f.name] = cli_value
if not overrides:
return config
from dataclasses import asdict
merged = asdict(config) # type: ignore[arg-type]
merged.update(overrides)
return type(config)(**merged) # type: ignore[return-value]
# ---------------------------------------------------------------------------
# CLI: dotted-path style (apply_overrides)
# ---------------------------------------------------------------------------
def apply_overrides(
config: T,
overrides: dict[str, str],
prefix: str = "",
) -> T:
"""Apply dotted-path string overrides to a config dataclass.
Used with a unified ``--config KEY=VALUE`` CLI flag. Each key
is a dotted path relative to the prefix.
Example::
overrides = {
"provider.base_url": "http://localhost:4000",
"provider.model": "qwen2.5:7b",
}
config = apply_overrides(config, overrides, prefix="provider")
# config.base_url == "http://localhost:4000"
# config.model == "qwen2.5:7b"
:param config: a dataclass instance.
:param overrides: dict of dotted keys to string values.
:param prefix: only apply keys starting with this prefix.
:returns: new config instance with overrides applied.
"""
hints = resolve_hints(type(config))
kwargs: dict[str, Any] = {}
changed = False
for f in fields(config):
if not f.init:
continue
full_key = f"{prefix}.{f.name}" if prefix else f.name
if full_key in overrides:
raw = overrides[full_key]
field_type = hints.get(f.name, str)
coerce = _TYPE_MAP.get(field_type, field_type)
kwargs[f.name] = coerce(raw)
changed = True
else:
kwargs[f.name] = getattr(config, f.name)
if not changed:
return config
return type(config)(**kwargs) # type: ignore[return-value]
def format_help(cls: Type, prefix: str = "") -> list[str]:
"""Generate help lines for a config dataclass.
Each line shows the dotted key path, type, and default value.
Suitable for CLI epilog text.
:param cls: a dataclass class.
:param prefix: prepended to each key path.
:returns: list of formatted help strings.
"""
hints = resolve_hints(cls)
lines = []
for f in fields(cls):
if not f.init:
continue
field_type = hints.get(f.name, str)
type_name = getattr(field_type, "__name__", str(field_type))
key = f"{prefix}.{f.name}" if prefix else f.name
if f.default is not MISSING:
default = f.default
elif f.default_factory is not MISSING: # type: ignore[arg-type]
default = repr(f.default_factory()) # type: ignore[misc]
else:
default = "(required)"
lines.append(f" {key} ({type_name}, default: {default})")
return lines
# ---------------------------------------------------------------------------
# Backwards compat
# ---------------------------------------------------------------------------
# keep the old private name working for existing callers
_resolve_hints = resolve_hints

View file

@ -1,201 +0,0 @@
#!/usr/bin/env python3
"""Generic HTTP client.
Thin urllib wrapper with retry-on-rate-limit. No domain knowledge
GitHub, Bitbucket, etc. are handled by higher-level modules.
"""
from dataclasses import dataclass
import http.cookiejar
import json
import time
from typing import Any, Dict, Optional
import urllib.request
import urllib.parse
from warnings import warn
@dataclass(frozen=True)
class HttpResponse:
status_code: int
headers: dict[str, str]
data: bytes
reason: Optional[str] = None
def json(self):
return json.loads(self.data.decode("utf-8"))
@property
def text(self) -> str:
return self.data.decode("utf-8", errors="replace")
class HttpSession:
"""HTTP client that persists cookies across requests.
Suitable for sites that require login followed by
cookie-authenticated page fetches.
"""
def __init__(
self,
default_headers: dict[str, str] | None = None,
timeout: int = 30,
) -> None:
self._timeout = timeout
self._default_headers = default_headers or {}
self._jar = http.cookiejar.CookieJar()
self._opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self._jar),
)
def get(
self,
url: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> HttpResponse:
if params:
query = urllib.parse.urlencode(params)
url = f"{url}?{query}"
req = urllib.request.Request(
url,
headers=self._merged_headers(headers),
method="GET",
)
return self._send(req)
def post(
self,
url: str,
data: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> HttpResponse:
body = (
urllib.parse.urlencode(data).encode()
if data else None
)
merged = self._merged_headers(headers)
if data and "Content-Type" not in merged:
merged["Content-Type"] = (
"application/x-www-form-urlencoded"
)
req = urllib.request.Request(
url,
data=body,
headers=merged,
method="POST",
)
return self._send(req)
def _send(self, req: urllib.request.Request) -> HttpResponse:
try:
with self._opener.open(
req, timeout=self._timeout
) as resp:
return HttpResponse(
status_code=resp.getcode(),
headers=dict(resp.getheaders()),
data=resp.read(),
)
except urllib.error.HTTPError as e:
return HttpResponse(
status_code=e.code,
headers=dict(e.headers.items()),
data=e.read(),
)
def _merged_headers(
self, extra: dict[str, str] | None
) -> dict[str, str]:
merged = dict(self._default_headers)
if extra:
merged.update(extra)
return merged
def _request(
url: str,
method: str = "GET",
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
data: Optional[bytes] = None,
) -> HttpResponse:
# TODO: do proper exponential backoff
backoff = [1, 2, 4]
if params:
query = urllib.parse.urlencode(params)
url = f"{url}?{query}"
req = urllib.request.Request(
url,
headers=headers or {},
method=method,
data=data,
)
for delay in backoff:
try:
with urllib.request.urlopen(req, timeout=30) as resp:
status = resp.getcode()
resp_data = resp.read()
resp_headers = dict(resp.getheaders())
if status == 429:
warn(f"Rate-limited on {url} (HTTP {status})."
f" Backing off {delay}s...")
time.sleep(delay)
continue
return HttpResponse(
status, resp_headers, resp_data, resp.reason,
)
except urllib.error.HTTPError as e:
status = e.code
err_data = e.read()
err_headers = dict(e.headers.items())
if status == 429:
warn(f"Rate-limited on {url} (HTTP {status})."
f" Backing off {delay}s...")
time.sleep(delay)
continue
return HttpResponse(
status, err_headers, err_data, e.reason,
)
except urllib.error.URLError as e:
raise Exception(
"Network error on %s: %s", url, e,
) from e
# If all retries exhausted, return last error-like response
return HttpResponse(503, {}, b"", "Service unavailable")
def get(
url: str,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> HttpResponse:
return _request(url, method="GET", params=params, headers=headers)
def post(
url: str,
data: Optional[bytes] = None,
headers: Optional[Dict[str, str]] = None,
) -> HttpResponse:
return _request(url, method="POST", headers=headers, data=data)
def put(
url: str,
data: Optional[bytes] = None,
headers: Optional[Dict[str, str]] = None,
) -> HttpResponse:
return _request(url, method="PUT", headers=headers, data=data)

View file

@ -0,0 +1,364 @@
from __future__ import annotations
from html.parser import HTMLParser
import re
from typing import Dict, Iterable, List, Optional, Generator, Union
class Node:
"""
Represents a node in a simple DOM-like tree.
:param tag: The HTML tag name (e.g., ``"div"``). ``None`` for text nodes.
:param attrs: Iterable of ``(key, value)`` attribute pairs.
:param parent: Parent :class:`Node` instance.
:param text: Text content for text nodes.
.. todo::
Mutation APIs (append_child, remove, replace_with)
"""
def __init__(
self,
tag: Optional[str] = None,
attrs: Optional[Iterable[tuple[str, str]]] = None,
parent: Optional["Node"] = None,
text: str = "",
) -> None:
self.tag: Optional[str] = tag
self.attrs: Dict[str, str] = dict(attrs or [])
self.parent: Optional["Node"] = parent
self.children: List["Node"] = []
self.text: str = text
def __repr__(self) -> str:
return f"<Node {self.tag} {self.attrs} children={len(self.children)}>"
# ----------------------------------------------------------------------
# Tree traversal
# ----------------------------------------------------------------------
def iter(self) -> Generator["Node", None, None]:
"""
Recursively yield all descendant nodes.
:return: Generator of :class:`Node` objects.
"""
for child in self.children:
yield child
yield from child.iter()
# ----------------------------------------------------------------------
# DOM-like lookup helpers
# ----------------------------------------------------------------------
def get_elements_by_tag_name(self, tag: str) -> List["Node"]:
"""
Return all descendant elements with the given tag name.
:param tag: Tag name to match.
:return: List of :class:`Node` objects.
"""
return [n for n in self.iter() if n.tag == tag]
def get_elements_by_class_name(self, class_name: str) -> List["Node"]:
"""
Return all descendant elements that contain the given CSS class.
:param class_name: Class name to match.
:return: List of :class:`Node` objects.
"""
return [
n
for n in self.iter()
if "class" in n.attrs and class_name in n.attrs["class"].split()
]
def get_element_by_id(self, element_id: str) -> Optional["Node"]:
"""
Return the first descendant element with the given ``id`` attribute.
:param element_id: ID value to match.
:return: :class:`Node` or ``None``.
"""
for n in self.iter():
if n.attrs.get("id") == element_id:
return n
return None
def get_elements_by_attribute(
self, attr: str, value: Optional[str] = None
) -> List["Node"]:
"""
Return all descendant elements matching an attribute.
:param attr: Attribute name.
:param value: Optional value to match. If ``None``, only the presence
of the attribute is checked.
:return: List of :class:`Node` objects.
"""
if value is None:
return [n for n in self.iter() if attr in n.attrs]
return [n for n in self.iter() if n.attrs.get(attr) == value]
# ----------------------------------------------------------------------
# CSS selector engine (supports chaining)
# ----------------------------------------------------------------------
def query_selector(self, selector: str) -> Optional["Node"]:
"""
Return the first element matching a CSS-like selector.
Supports:
- ``tag``
- ``.class``
- ``#id``
:param selector: CSS selector string.
:return: :class:`Node` or ``None``.
"""
results = self.query_selector_all(selector)
return results[0] if results else None
def query_selector_all(self, selector: str) -> List["Node"]:
# Tokenize: split on spaces and > while keeping >
tokens = re.findall(r"[^\s>]+|>", selector)
# Current working set starts with the context node
current = [self]
# Helper: match a node against a simple selector
def match_simple(node: "Node", token: str) -> bool:
tag = None
id_ = None
classes = []
attrs = {}
# [attr=value]
attr_matches = re.findall(
r"\[([a-zA-Z0-9_-]+)=['\"]?([^'\"]+)['\"]?\]",
token
)
for k, v in attr_matches:
attrs[k] = v
token = re.sub(r"\[[^\]]+\]", "", token)
# tag
m = re.match(r"^[a-zA-Z0-9_-]+", token)
if m:
tag = m.group(0)
token = token[len(tag):]
# #id
m = re.search(r"#([a-zA-Z0-9_-]+)", token)
if m:
id_ = m.group(1)
token = token.replace("#" + id_, "")
# .classes
classes = [c for c in token.split(".") if c]
# match
if tag and node.tag != tag:
return False
if id_ and node.attrs.get("id") != id_:
return False
for cls in classes:
if "class" not in node.attrs or cls not in node.attrs["class"].split():
return False
for k, v in attrs.items():
if node.attrs.get(k) != v:
return False
return True
# ------------------------------------------------------------
# Main selector evaluation
# ------------------------------------------------------------
first_token = True
i = 0
while i < len(tokens):
token = tokens[i]
# --------------------------------------------------------
# Direct child selector
# --------------------------------------------------------
if token == ">":
i += 1
next_token = tokens[i]
next_nodes = []
for node in current:
for child in node.children:
if match_simple(child, next_token):
next_nodes.append(child)
current = next_nodes
first_token = False
i += 1
continue
# --------------------------------------------------------
# Descendant selector
# --------------------------------------------------------
next_nodes = []
seen = set()
for node in current:
# Only include the context node itself if NOT the first token
if not first_token and match_simple(node, token):
if id(node) not in seen:
seen.add(id(node))
next_nodes.append(node)
# Always include descendants
for desc in node.iter():
if match_simple(desc, token):
if id(desc) not in seen:
seen.add(id(desc))
next_nodes.append(desc)
current = next_nodes
first_token = False
i += 1
return current
def xpath(self, expr: str) -> List["Node"]:
"""
Very small XPath subset:
- ``//tag``
- ``tag/subtag``
- ``//tag[@attr="value"]``
:param expr: XPath-like expression.
:return: List of :class:`Node` objects.
.. todo::
full XPath 1.0 subset
"""
expr = expr.strip()
parts = expr.split("/")
current: List[Node] = [self]
def match(nodes: List[Node], tag: str, attr: Optional[str], val: Optional[str]) -> List[Node]:
out: List[Node] = []
for n in nodes:
candidates = n.iter()
for c in candidates:
if tag != "*" and c.tag != tag:
continue
if attr:
if c.attrs.get(attr) == val:
out.append(c)
else:
out.append(c)
return out
i = 0
while i < len(parts):
part = parts[i]
if not part:
i += 1
continue
# //tag
if part.startswith("//"):
tag = part[2:]
attr = val = None
if "[" in tag:
tag, rest = tag.split("[", 1)
rest = rest.rstrip("]")
attr, val = rest.split("=")
attr = attr.strip("@")
val = val.strip('"').strip("'")
current = match(current, tag, attr, val)
i += 1
continue
# tag[@attr="value"]
if "[" in part:
tag, rest = part.split("[", 1)
rest = rest.rstrip("]")
attr, val = rest.split("=")
attr = attr.strip("@")
val = val.strip('"').strip("'")
current = match(current, tag, attr, val)
else:
current = match(current, part, None, None)
i += 1
return current
@property
def inner_content(self) -> str:
"""
Return the concatenated text content of this node and all descendants.
:return: String containing all text content.
"""
parts: List[str] = []
if self.text:
parts.append(self.text)
for c in self.children:
parts.append(c.inner_content)
return "".join(parts)
def outer_html(self) -> str:
"""
Reconstruct the HTML for this node and its subtree.
:return: HTML string.
"""
if self.tag is None:
return self.text
attrs = "".join(f' {k}="{v}"' for k, v in self.attrs.items())
inner = "".join(child.outer_html() for child in self.children)
return f"<{self.tag}{attrs}>{inner}</{self.tag}>"
def pretty(self, indent: int = 0) -> str:
"""
Return a pretty-printed representation of the DOM tree.
:param indent: Current indentation level.
:return: Multiline string.
"""
pad = " " * indent
if self.tag is None:
return f"{pad}{self.text!r}"
attrs = " ".join(f'{k}="{v}"' for k, v in self.attrs.items())
header = f"{pad}<{self.tag} {attrs}>".rstrip()
lines = [header]
for child in self.children:
lines.append(child.pretty(indent + 1))
return "\n".join(lines)
class TreeBuilder(HTMLParser):
"""
HTML parser that constructs a simple DOM-like tree of :class:`Node` objects.
"""
def __init__(self) -> None:
super().__init__()
self.root: Node = Node(tag="__root__")
self.current: Node = self.root
def handle_starttag(self, tag: str, attrs: List[tuple[str, str]]) -> None:
node = Node(tag=tag, attrs=attrs, parent=self.current)
self.current.children.append(node)
self.current = node
def handle_endtag(self, tag: str) -> None:
if self.current.parent is not None:
self.current = self.current.parent
def handle_data(self, data: str) -> None:
if data.strip():
self.current.children.append(Node(text=data, parent=self.current))

View file

@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""Bitbucket Cloud REST API v2.0 wrapper.
Thin layer over http.py for Bitbucket-specific operations:
- Bearer token authentication
- Repository existence checks
- Repository creation within a workspace/project
"""
import json
from typing import Any, Dict, Optional
from byteb4rb1e.utils.http import client as http_client
BITBUCKET_API = "https://api.bitbucket.org/2.0"
def http_headers(token: str) -> Dict[str, str]:
"""Construct Bitbucket API headers with Bearer token auth."""
return {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def repository_exists(
workspace: str,
repo_slug: str,
token: str,
) -> bool:
"""Check whether a repository exists in the workspace."""
url = f"{BITBUCKET_API}/repositories/{workspace}/{repo_slug}"
resp = http_client.get(url, headers=http_headers(token))
return resp.status_code == 200
def create_repository(
workspace: str,
repo_slug: str,
token: str,
project: Optional[str] = None,
description: str = "",
is_private: bool = True,
) -> http_client.HttpResponse:
"""Create a new repository in the workspace.
When *project* is given the repository is assigned to that
Bitbucket project (by key). This is required for workspaces
that scope access keys at the project level.
Returns the API response. Caller should check status_code == 200
for success.
"""
url = f"{BITBUCKET_API}/repositories/{workspace}/{repo_slug}"
body: Dict[str, Any] = {
"scm": "git",
"is_private": is_private,
"description": description,
"fork_policy": "no_forks",
}
if project:
body["project"] = {"key": project}
return http_client.put(
url,
data=json.dumps(body).encode("utf-8"),
headers=http_headers(token),
)
def clone_url(
workspace: str,
repo_slug: str,
) -> str:
"""Return the SSH clone URL for a Bitbucket repository."""
return f"git@bitbucket.org:{workspace}/{repo_slug}.git"

View file

@ -1,98 +0,0 @@
#!/usr/bin/env python3
"""Forgejo REST API v1 wrapper.
Thin layer over http.py for Forgejo-specific operations:
- Token authentication
- Repository existence checks
- Repository creation under the authenticated user or an organization
- SSH and HTTPS clone URL construction
Unlike Bitbucket (one global SaaS instance), Forgejo is self-hosted,
so every operation takes a *host* parameter instead of baking any
specific instance in.
"""
import json
from typing import Any, Dict, Optional
from byteb4rb1e.utils.http import client as http_client
def api_url(host: str) -> str:
"""Return the API base URL for a Forgejo instance."""
return f"https://{host}/api/v1"
def http_headers(token: str) -> Dict[str, str]:
"""Construct Forgejo API headers with token auth."""
return {
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def repository_exists(
host: str,
owner: str,
repo_slug: str,
token: str,
) -> bool:
"""Check whether a repository exists under the owner."""
url = f"{api_url(host)}/repos/{owner}/{repo_slug}"
resp = http_client.get(url, headers=http_headers(token))
return bool(resp.status_code == 200)
def create_repository(
host: str,
repo_slug: str,
token: str,
org: Optional[str] = None,
description: str = "",
is_private: bool = True,
) -> http_client.HttpResponse:
"""Create a new repository on the Forgejo instance.
When *org* is given the repository is created in that
organization, otherwise under the authenticated user.
Returns the API response. Caller should check status_code == 201
for success.
"""
if org:
url = f"{api_url(host)}/orgs/{org}/repos"
else:
url = f"{api_url(host)}/user/repos"
body: Dict[str, Any] = {
"name": repo_slug,
"private": is_private,
"description": description,
}
return http_client.post(
url,
data=json.dumps(body).encode("utf-8"),
headers=http_headers(token),
)
def ssh_clone_url(
host: str,
owner: str,
repo_slug: str,
) -> str:
"""Return the SSH clone URL for a Forgejo repository."""
return f"git@{host}:{owner}/{repo_slug}.git"
def https_clone_url(
host: str,
owner: str,
repo_slug: str,
) -> str:
"""Return the HTTPS clone URL for a Forgejo repository.
Preferred in CI environments without SSH host keys.
"""
return f"https://{host}/{owner}/{repo_slug}.git"

View file

@ -1,65 +0,0 @@
#!/usr/bin/env python3
import hashlib
from pathlib import Path
from typing import Any, Dict, List, Optional
from byteb4rb1e.utils.http import client as http_client
GITHUB_API = "https://api.github.com"
def http_headers(token: Optional[str]) -> Dict[str, str]:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "sphinx-h5p-worker1"
}
if token:
# Use standard PAT header; token not logged anywhere.
headers["Authorization"] = f"Bearer {token}"
return headers
def blob_sha(path: Path) -> str:
"""Calculate Git blob SHA-1 for a file, matching GitHub API 'sha'."""
data = path.read_bytes()
header = f"blob {len(data)}\0".encode("utf-8")
store = header + data
return hashlib.sha1(store).hexdigest()
def list_org_repos(org: str, token: Optional[str]) -> List[Dict[str, Any]]:
repos: List[Dict[str, Any]] = []
page = 1
per_page = 100
while True:
url = f"{GITHUB_API}/orgs/{org}/repos"
resp = http_client.get(
url,
params={"page": page, "per_page": per_page, "type": "public"},
headers=http_headers(token),
)
if resp.status_code != 200:
raise RuntimeError(f"Failed to list repos for org {org}: {resp.status_code} {resp.text}")
batch = resp.json()
if not batch:
break
repos.extend(batch)
page += 1
return repos
def fetch_file(
org: str,
repo: str,
path: str,
token: str
) -> http_client.HttpResponse:
"""
"""
url = f"{GITHUB_API}/repos/{org}/{repo}/{path}"
return http_client.get(
url,
headers=http_headers(token),
)

View file

@ -1,331 +0,0 @@
#!/usr/bin/env python3
"""Git subprocess wrapper for repository operations.
Provides primitives for mirror cloning, syncing, remote management,
file extraction from bare repos, and submodule management.
No pygit2 or gitpython, uses subprocess only.
"""
import logging
import subprocess
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class GitError(Exception):
"""A git subprocess returned a non-zero exit code."""
def __init__(self, args: List[str], returncode: int, stderr: str):
self.args_list = args
self.returncode = returncode
self.stderr = stderr
super().__init__(
f"git exited {returncode}: {' '.join(args)}\n{stderr}"
)
def parse_base_url(base_url: str) -> str:
"""Extract the workspace from an SCP-style base URL.
Accepts any host (Bitbucket, Forgejo, GitHub, ...) as long as
the URL is SCP-style::
git@bitbucket.org:byteb4rb1e/foo.git byteb4rb1e
git@git.code.tiararodney.com:h5p-mirror/foo.git h5p-mirror
"""
# SCP-style: git@host:workspace/repo
if ":" not in base_url or "//" in base_url:
raise ValueError(
f"Expected SCP-style URL (git@host:workspace), "
f"got: {base_url}"
)
_, workspace = base_url.split(":", 1)
return str(Path(workspace).parent)
def parse_repo_name(base_url: str) -> str:
"""Extract the repository name from an SCP-style base URL.
Accepts any host (Bitbucket, Forgejo, GitHub, ...) as long as
the URL is SCP-style::
git@bitbucket.org:byteb4rb1e/foo.git foo
git@git.code.tiararodney.com:h5p-mirror/foo.git foo
"""
# SCP-style: git@host:workspace/repo
if ":" not in base_url or "//" in base_url:
raise ValueError(
f"Expected SCP-style URL (git@host:workspace), "
f"got: {base_url}"
)
_, workspace = base_url.split(":", 1)
return Path(workspace).name.split('.')[0]
def _run(
args: List[str],
cwd: Optional[Path] = None,
capture_stdout: bool = False,
) -> subprocess.CompletedProcess: # type: ignore[type-arg]
"""Run a git command, raising GitError on failure."""
cmd = ["git"] + args
logger.debug("$ %s", " ".join(cmd))
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise GitError(cmd, result.returncode, result.stderr.strip())
return result
def mirror_clone(source_url: str, dest: Path) -> None:
"""Clone a repository as a bare mirror.
Equivalent to ``git clone --mirror <source_url> <dest>``.
The destination directory must not already exist.
"""
_run(["clone", "--mirror", source_url, str(dest)])
logger.info("Cloned mirror %s%s", source_url, dest)
def add_remote(repo: Path, name: str, url: str) -> None:
"""Add a named remote to a bare repository."""
_run(["remote", "add", name, url], cwd=repo)
logger.debug("Added remote %s%s in %s", name, url, repo)
def has_remote(repo: Path, name: str) -> bool:
"""Check whether a named remote exists."""
result = _run(["remote"], cwd=repo)
return name in result.stdout.splitlines()
def mirror_update(repo: Path) -> None:
"""Fetch all remotes in a bare mirror repository.
Equivalent to ``git remote update`` inside the bare repo.
"""
_run(["remote", "update"], cwd=repo)
logger.debug("Updated remotes in %s", repo)
def fetch(repo: Path, remote: str = "origin") -> None:
"""Fetch from a single remote."""
_run(["fetch", remote], cwd=repo)
logger.debug("fetched %s in %s", remote, repo)
def show_ref(repo: Path) -> str:
"""Return the raw output of ``git show-ref`` (all refs + SHAs).
Returns an empty string if the repo has no refs.
"""
try:
result = _run(["show-ref"], cwd=repo)
return result.stdout
except GitError:
return ""
def ls_remote(repo: Path, remote: str) -> str:
"""Return the raw output of ``git ls-remote <remote>``.
Returns an empty string if the remote has no refs or on error.
"""
try:
result = _run(["ls-remote", remote], cwd=repo)
return result.stdout
except GitError:
return ""
def mirror_push(repo: Path, remote: str) -> None:
"""Push the full mirror to a remote.
Equivalent to ``git push --mirror <remote>``.
"""
_run(["push", "--mirror", remote], cwd=repo)
logger.info("Pushed mirror to %s from %s", remote, repo)
def read_file(
repo: Path,
filepath: str,
ref: str = "HEAD",
) -> Optional[str]:
"""Extract a file's contents from a bare repo without checkout.
Returns the file content as a string, or None if the file does
not exist at the given ref.
"""
try:
result = _run(
["show", f"{ref}:{filepath}"],
cwd=repo,
capture_stdout=True,
)
return result.stdout
except GitError:
return None
# -------------------------------------------------------------------
# Ref / tag primitives
# -------------------------------------------------------------------
def list_tags(repo: Path) -> List[str]:
"""List all tags in a repository."""
result = _run(["tag", "-l"], cwd=repo)
return [t for t in result.stdout.splitlines() if t]
def resolve_ref(repo: Path, ref: str) -> str:
"""Resolve a ref to a full SHA.
Raises GitError if the ref cannot be resolved.
"""
result = _run(
["rev-parse", ref], cwd=repo, capture_stdout=True,
)
return result.stdout.strip()
def head_ref(repo: Path) -> str:
"""Return the full SHA of HEAD."""
return resolve_ref(repo, "HEAD")
# -------------------------------------------------------------------
# Pull-through bare clone cache
# -------------------------------------------------------------------
def bare_path_for_url(url: str, cache_dir: Path) -> Path:
"""Derive a cache path from a clone URL.
Strips scheme/host, keeps the path component, appends ``.git``.
Examples::
https://github.com/h5p/h5p-multi-choice
cache_dir / h5p / h5p-multi-choice.git
git@github.com:h5p/h5p-multi-choice.git
cache_dir / h5p / h5p-multi-choice.git
"""
# Handle SCP-style URLs (git@host:path)
if ":" in url and "//" not in url:
path_part = url.split(":", 1)[1]
else:
# Strip scheme + host
from urllib.parse import urlparse
parsed = urlparse(url)
path_part = parsed.path.lstrip("/")
# Strip trailing .git if present, then re-add it
if path_part.endswith(".git"):
path_part = path_part[:-4]
return cache_dir / (path_part + ".git")
def ensure_bare_clone(url: str, cache_dir: Path) -> Path:
"""Ensure a bare mirror clone exists in *cache_dir*.
If the bare repo already exists, fetches updates via
``mirror_update``. Otherwise, creates a new mirror clone.
Returns the path to the bare repo.
"""
bare_path = bare_path_for_url(url, cache_dir)
if bare_path.exists():
mirror_update(bare_path)
logger.debug("Updated existing cache %s", bare_path)
else:
bare_path.parent.mkdir(parents=True, exist_ok=True)
mirror_clone(url, bare_path)
logger.info("Cached new bare clone %s", bare_path)
return bare_path
# -------------------------------------------------------------------
# Submodule operations
# -------------------------------------------------------------------
def has_submodule(repo: Path, path: str) -> bool:
"""Check whether a submodule is registered at *path*.
Reads ``.gitmodules`` to determine whether the submodule exists.
*path* is resolved relative to *repo*, then compared against
the repository root so the check works when *repo* is a
subdirectory of the actual git working tree.
Returns False if ``.gitmodules`` does not exist.
"""
try:
toplevel = Path(
_run(
["rev-parse", "--show-toplevel"], cwd=repo,
).stdout.strip()
)
except GitError:
return False
gitmodules = toplevel / ".gitmodules"
if not gitmodules.is_file():
return False
# Resolve the full path relative to the repo root
full_path = (repo / path).resolve()
try:
rel_path = str(full_path.relative_to(toplevel.resolve()))
except ValueError:
return False
try:
result = _run(
["config", "--file", str(gitmodules),
"--get-regexp", r"submodule\..*\.path"],
cwd=toplevel,
)
except GitError:
return False
for line in result.stdout.splitlines():
parts = line.split(None, 1)
if len(parts) == 2 and parts[1] == rel_path:
return True
return False
def submodule_add(repo: Path, url: str, path: str) -> None:
"""Add a git submodule at *path* pointing to *url*.
Equivalent to ``git submodule add <url> <path>`` inside *repo*.
"""
_run(["submodule", "add", url, path], cwd=repo)
logger.info("Added submodule %s%s", url, path)
def submodule_update(repo: Path, path: str) -> None:
"""Fetch and update a submodule to the latest remote HEAD.
Enters the submodule directory, fetches origin, and checks out
the latest commit on the remote default branch.
"""
sub_path = repo / path
_run(["fetch", "origin"], cwd=sub_path)
# Determine default branch from remote HEAD
result = _run(
["symbolic-ref", "refs/remotes/origin/HEAD",
"--short"],
cwd=sub_path,
)
default_branch = result.stdout.strip()
_run(["checkout", default_branch], cwd=sub_path)
logger.info("Updated submodule %s to %s", path, default_branch)
def submodule_checkout(repo: Path, path: str, ref: str) -> None:
"""Fetch and checkout a specific ref in a submodule."""
sub_path = repo / path
_run(["fetch", "origin"], cwd=sub_path)
_run(["checkout", ref], cwd=sub_path)
logger.info("Checked out submodule %s at %s", path, ref)

View file

@ -1,52 +0,0 @@
"""Tests for custom argparse actions."""
from argparse import ArgumentParser
import pytest
from byteb4rb1e.utils.argparse.actions import KeyValueAction
def _parse(*args):
parser = ArgumentParser()
parser.add_argument("--config", action=KeyValueAction, default={}, metavar="KEY=VALUE")
return parser.parse_args(list(args))
class TestKeyValueAction:
def test_single_pair(self):
args = _parse("--config", "key=value")
assert args.config == {"key": "value"}
def test_multiple_pairs(self):
args = _parse("--config", "a=1", "--config", "b=2")
assert args.config == {"a": "1", "b": "2"}
def test_dotted_key(self):
args = _parse("--config", "provider.base_url=http://localhost")
assert args.config == {"provider.base_url": "http://localhost"}
def test_value_with_equals(self):
args = _parse("--config", "url=http://host?a=1&b=2")
assert args.config == {"url": "http://host?a=1&b=2"}
def test_empty_value(self):
args = _parse("--config", "key=")
assert args.config == {"key": ""}
def test_strips_whitespace(self):
args = _parse("--config", " key = value ")
assert args.config == {"key": "value"}
def test_overwrites_duplicate_key(self):
args = _parse("--config", "key=first", "--config", "key=second")
assert args.config == {"key": "second"}
def test_default_empty_dict(self):
args = _parse()
assert args.config == {}
def test_no_equals_raises(self):
with pytest.raises(SystemExit):
_parse("--config", "no_equals_here")

View file

@ -1,347 +0,0 @@
"""Unit tests for the config framework."""
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass
from pathlib import Path
import pytest
from byteb4rb1e.utils.config import (
add_config_arguments,
apply_cli_overrides,
apply_overrides,
ensure_ini,
ensure_ini_multi,
format_help,
format_section,
load_ini,
resolve_hints,
)
@dataclass
class SampleConfig:
name: str = "default"
count: int = 10
ratio: float = 0.5
enabled: bool = True
class TestLoadIni:
def test_loads_values(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text(
"[sample]\n"
"name = custom\n"
"count = 42\n"
"ratio = 0.75\n"
)
config = load_ini(SampleConfig, ini)
assert config.name == "custom"
assert config.count == 42
assert config.ratio == 0.75
assert config.enabled is True # default
def test_missing_section_uses_defaults(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text("[other]\nfoo = bar\n")
config = load_ini(SampleConfig, ini)
assert config.name == "default"
assert config.count == 10
def test_missing_file_uses_defaults(self, tmp_path):
config = load_ini(
SampleConfig, tmp_path / "missing.ini"
)
assert config.name == "default"
def test_unknown_key_raises(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text("[sample]\nunknown_key = bad\n")
with pytest.raises(ValueError, match="unknown_key"):
load_ini(SampleConfig, ini)
def test_custom_section_name(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text("[mysection]\nname = custom\n")
config = load_ini(
SampleConfig, ini, section="mysection"
)
assert config.name == "custom"
def test_comments_ignored(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text(
"[sample]\n"
"# this is a comment\n"
"name = works # inline comment\n"
)
config = load_ini(SampleConfig, ini)
assert config.name == "works"
class TestAddConfigArguments:
def test_generates_flags(self):
parser = ArgumentParser()
add_config_arguments(SampleConfig, parser)
args = parser.parse_args(
["--name", "cli", "--count", "99"]
)
assert args.name == "cli"
assert args.count == 99
def test_defaults_are_none(self):
parser = ArgumentParser()
add_config_arguments(SampleConfig, parser)
args = parser.parse_args([])
assert args.name is None
assert args.count is None
def test_underscores_become_dashes(self):
@dataclass
class DashConfig:
my_long_name: str = "x"
parser = ArgumentParser()
add_config_arguments(DashConfig, parser)
args = parser.parse_args(
["--my-long-name", "val"]
)
assert args.my_long_name == "val"
class TestApplyCliOverrides:
def test_overrides_set_values(self):
config = SampleConfig()
args = Namespace(name="override", count=None,
ratio=None, enabled=None)
result = apply_cli_overrides(config, args)
assert result.name == "override"
assert result.count == 10 # unchanged
def test_no_overrides_returns_same(self):
config = SampleConfig()
args = Namespace(name=None, count=None,
ratio=None, enabled=None)
result = apply_cli_overrides(config, args)
assert result.name == "default"
assert result is config
class TestEnsureIni:
def test_creates_file_if_missing(self, tmp_path):
ini = tmp_path / "new.ini"
assert not ini.exists()
config = ensure_ini(SampleConfig, ini)
assert ini.exists()
assert config.name == "default"
assert config.count == 10
def test_created_file_has_all_fields(self, tmp_path):
ini = tmp_path / "new.ini"
ensure_ini(SampleConfig, ini)
content = ini.read_text()
assert "name" in content
assert "count" in content
assert "ratio" in content
assert "enabled" in content
def test_created_file_has_comments(self, tmp_path):
ini = tmp_path / "new.ini"
ensure_ini(SampleConfig, ini)
content = ini.read_text()
assert "# name (str)" in content
assert "# count (int)" in content
def test_reads_existing_file(self, tmp_path):
ini = tmp_path / "existing.ini"
ini.write_text("[sample]\ncount = 42\n")
config = ensure_ini(SampleConfig, ini)
assert config.count == 42
def test_does_not_overwrite_existing(self, tmp_path):
ini = tmp_path / "existing.ini"
ini.write_text("[sample]\ncount = 42\n")
ensure_ini(SampleConfig, ini)
content = ini.read_text()
assert content == "[sample]\ncount = 42\n"
def test_created_file_is_loadable(self, tmp_path):
ini = tmp_path / "new.ini"
ensure_ini(SampleConfig, ini)
config = load_ini(SampleConfig, ini)
assert config.name == "default"
assert config.count == 10
assert config.ratio == 0.5
class TestIntegration:
def test_ini_then_cli_override(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text("[sample]\ncount = 42\n")
config = load_ini(SampleConfig, ini)
assert config.count == 42
args = Namespace(name=None, count=99,
ratio=None, enabled=None)
config = apply_cli_overrides(config, args)
assert config.count == 99
assert config.name == "default"
def test_ensure_then_cli_override(self, tmp_path):
ini = tmp_path / "new.ini"
config = ensure_ini(SampleConfig, ini)
assert config.count == 10
args = Namespace(name=None, count=99,
ratio=None, enabled=None)
config = apply_cli_overrides(config, args)
assert config.count == 99
assert config.name == "default"
# Config file unchanged
reloaded = load_ini(SampleConfig, ini)
assert reloaded.count == 10
class TestResolveHints:
def test_returns_type_dict(self):
hints = resolve_hints(SampleConfig)
assert hints["name"] is str
assert hints["count"] is int
assert hints["ratio"] is float
assert hints["enabled"] is bool
class TestFormatSection:
def test_includes_section_header(self):
text = format_section(SampleConfig)
assert "[sample]" in text
def test_custom_section_name(self):
text = format_section(SampleConfig, "custom")
assert "[custom]" in text
def test_includes_all_fields(self):
text = format_section(SampleConfig)
assert "name = default" in text
assert "count = 10" in text
assert "ratio = 0.5" in text
assert "enabled = True" in text
def test_includes_type_comments(self):
text = format_section(SampleConfig)
assert "# name (str)" in text
assert "# count (int)" in text
def test_is_loadable(self, tmp_path):
ini = tmp_path / "test.ini"
ini.write_text(format_section(SampleConfig) + "\n")
config = load_ini(SampleConfig, ini)
assert config.name == "default"
assert config.count == 10
class TestEnsureIniMulti:
def test_creates_file_with_multiple_sections(self, tmp_path):
@dataclass
class OtherConfig:
host: str = "localhost"
port: int = 8080
ini = tmp_path / "multi.ini"
ensure_ini_multi([
(SampleConfig, None),
(OtherConfig, "server"),
], ini)
content = ini.read_text()
assert "[sample]" in content
assert "[server]" in content
assert "name = default" in content
assert "host = localhost" in content
def test_does_not_overwrite_existing(self, tmp_path):
ini = tmp_path / "multi.ini"
ini.write_text("[existing]\nfoo = bar\n")
ensure_ini_multi([(SampleConfig, None)], ini)
content = ini.read_text()
assert content == "[existing]\nfoo = bar\n"
def test_sections_are_loadable(self, tmp_path):
@dataclass
class DbConfig:
url: str = "sqlite:///test.db"
ini = tmp_path / "multi.ini"
ensure_ini_multi([
(SampleConfig, None),
(DbConfig, "database"),
], ini)
sample = load_ini(SampleConfig, ini)
db = load_ini(DbConfig, ini, section="database")
assert sample.name == "default"
assert db.url == "sqlite:///test.db"
class TestApplyOverrides:
def test_applies_dotted_path(self):
config = SampleConfig()
result = apply_overrides(config, {
"provider.name": "custom",
"provider.count": "99",
}, prefix="provider")
assert result.name == "custom"
assert result.count == 99
def test_without_prefix(self):
config = SampleConfig()
result = apply_overrides(config, {
"name": "direct",
"count": "42",
})
assert result.name == "direct"
assert result.count == 42
def test_no_matching_keys_returns_same(self):
config = SampleConfig()
result = apply_overrides(config, {"other.key": "val"}, prefix="provider")
assert result is config
def test_bool_coercion(self):
config = SampleConfig()
result = apply_overrides(config, {"enabled": "false"})
assert result.enabled is False
def test_preserves_unset_fields(self):
config = SampleConfig()
result = apply_overrides(config, {"name": "changed"})
assert result.name == "changed"
assert result.count == 10 # unchanged
assert result.ratio == 0.5 # unchanged
class TestFormatHelp:
def test_lists_all_fields(self):
lines = format_help(SampleConfig)
assert len(lines) == 4
assert any("name" in l for l in lines)
assert any("count" in l for l in lines)
def test_includes_types(self):
lines = format_help(SampleConfig)
text = "\n".join(lines)
assert "str" in text
assert "int" in text
def test_includes_defaults(self):
lines = format_help(SampleConfig)
text = "\n".join(lines)
assert "default" in text
assert "10" in text
def test_with_prefix(self):
lines = format_help(SampleConfig, prefix="provider")
assert any("provider.name" in l for l in lines)
assert any("provider.count" in l for l in lines)

View file

@ -0,0 +1,134 @@
import pytest
from byteb4rb1e.utils.http.parser import Node, TreeBuilder
@pytest.fixture
def sample_dom():
"""
Build a small DOM tree for testing:
"""
html = """
<div id="root" class="container">
<p class="text">Hello</p>
<span class="text highlight">World</span>
<div class="box">
<span id="inner">Inside</span>
<span id="inner2">Inside Too</span>
</div>
</div>
"""
parser = TreeBuilder()
parser.feed(html)
return parser.root.children[0] # the <div id="root">
class TestGetElementsByTagName:
def test_find_all_spans(self, sample_dom):
spans = sample_dom.get_elements_by_tag_name("span")
assert len(spans) == 3
assert spans[0].tag == "span"
assert spans[1].tag == "span"
assert spans[2].tag == "span"
def test_find_no_matches(self, sample_dom):
assert sample_dom.get_elements_by_tag_name("table") == []
class TestGetElementsByClassName:
def test_find_single_class(self, sample_dom):
items = sample_dom.get_elements_by_class_name("text")
assert len(items) == 2
def test_find_multiple_classes(self, sample_dom):
items = sample_dom.get_elements_by_class_name("highlight")
assert len(items) == 1
assert items[0].tag == "span"
def test_no_such_class(self, sample_dom):
assert sample_dom.get_elements_by_class_name("missing") == []
class TestGetElementById:
def test_find_existing_id(self, sample_dom):
node = sample_dom.get_element_by_id("inner")
assert node is not None
assert node.tag == "span"
assert node.inner_content == "Inside"
def test_missing_id(self, sample_dom):
assert sample_dom.get_element_by_id("nope") is None
class TestQuerySelectorAll:
def test_class_selector(self, sample_dom):
items = sample_dom.query_selector_all(".text")
assert len(items) == 2
def test_id_selector(self, sample_dom):
items = sample_dom.query_selector_all("#inner")
assert len(items) == 1
assert items[0].inner_content == "Inside"
def test_tag_selector(self, sample_dom):
items = sample_dom.query_selector_all("p")
assert len(items) == 1
assert items[0].inner_content == "Hello"
def test_chained_selector(self, sample_dom):
items = sample_dom.query_selector_all(".text .highlight")
assert len(items) == 1
assert items[0].inner_content == "World"
def test_direct_child(self, sample_dom):
items = sample_dom.query_selector_all(".box > #inner")
assert len(items) == 1
assert items[0].inner_content == "Inside"
def test_direct_child_no_match(self, sample_dom):
items = sample_dom.query_selector_all("div > span.highlight")
# highlight span is NOT a direct child of inner div
assert len(items) == 0
def test_attribute_match(self, sample_dom):
items = sample_dom.query_selector_all('[id="inner"]')
assert len(items) == 1
assert items[0].inner_content == "Inside"
def test_attribute_no_match(self, sample_dom):
items = sample_dom.query_selector_all('[data-x="nope"]')
assert items == []
def test_tag_class(self, sample_dom):
items = sample_dom.query_selector_all("span.highlight")
assert len(items) == 1
assert items[0].inner_content == "World"
def test_multiple_classes(self, sample_dom):
items = sample_dom.query_selector_all(".text.highlight")
assert len(items) == 1
assert items[0].inner_content == "World"
def test_tag_id_class(self, sample_dom):
items = sample_dom.query_selector_all("span#inner")
assert len(items) == 1
assert items[0].inner_content == "Inside"
def test_descendant(self, sample_dom):
items = sample_dom.query_selector_all("div span")
assert len(items) == 2
class TestXPath:
def test_simple_tag(self, sample_dom):
spans = sample_dom.xpath("//span")
assert len(spans) == 3
def test_attribute_match(self, sample_dom):
nodes = sample_dom.xpath('//span[@id="inner"]')
assert len(nodes) == 1
assert nodes[0].inner_content == "Inside"
def test_nested(self, sample_dom):
nodes = sample_dom.xpath("//div[@class='box']")
assert len(nodes) == 1

View file

@ -1,217 +0,0 @@
"""Tests for the generic HTTP client."""
import email.message
import io
import urllib.error
import urllib.parse
import urllib.request
from types import TracebackType
from typing import Dict, List, Optional, Tuple, Type, Union
import pytest
from byteb4rb1e.utils.http.client import HttpResponse, HttpSession
class _FakeRawResponse:
"""Stands in for the object returned by OpenerDirector.open()."""
def __init__(
self,
status: int = 200,
headers: Optional[Dict[str, str]] = None,
data: bytes = b"",
) -> None:
self._status = status
self._headers = headers or {}
self._data = data
def getcode(self) -> int:
return self._status
def getheaders(self) -> List[Tuple[str, str]]:
return list(self._headers.items())
def read(self) -> bytes:
return self._data
def __enter__(self) -> "_FakeRawResponse":
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
tb: Optional[TracebackType],
) -> None:
return None
class _FakeOpener:
"""Records requests and replays canned responses."""
def __init__(
self,
responses: Optional[
List[Union[_FakeRawResponse, Exception]]
] = None,
) -> None:
self.requests: List[urllib.request.Request] = []
self._responses = list(responses or [_FakeRawResponse()])
def open(
self,
req: urllib.request.Request,
timeout: Optional[int] = None,
) -> _FakeRawResponse:
self.requests.append(req)
response = self._responses.pop(0)
if isinstance(response, Exception):
raise response
return response
def _http_error(
code: int = 404,
data: bytes = b"",
headers: Optional[Dict[str, str]] = None,
) -> urllib.error.HTTPError:
hdrs = email.message.Message()
for key, value in (headers or {}).items():
hdrs[key] = value
return urllib.error.HTTPError(
"http://testserver/", code, "error", hdrs, io.BytesIO(data),
)
class TestHttpResponse:
def test_json(self) -> None:
resp = HttpResponse(200, {}, b'{"a": 1}')
assert resp.json() == {"a": 1}
def test_text(self) -> None:
resp = HttpResponse(200, {}, b"hello")
assert resp.text == "hello"
def test_text_replaces_invalid_utf8(self) -> None:
resp = HttpResponse(200, {}, b"\xff\xfe")
assert "<EFBFBD>" in resp.text
def test_reason_defaults_to_none(self) -> None:
resp = HttpResponse(200, {}, b"")
assert resp.reason is None
def test_frozen(self) -> None:
resp = HttpResponse(200, {}, b"")
with pytest.raises(Exception):
resp.status_code = 500
class TestHttpSession:
def test_opener_has_cookie_processor(self) -> None:
session = HttpSession()
processors = [
h for h in session._opener.handlers
if isinstance(h, urllib.request.HTTPCookieProcessor)
]
assert len(processors) == 1
assert processors[0].cookiejar is session._jar
def test_get(self) -> None:
opener = _FakeOpener([
_FakeRawResponse(200, {"X-Foo": "bar"}, b"body"),
])
session = HttpSession()
session._opener = opener
resp = session.get("http://testserver/page")
assert resp.status_code == 200
assert resp.data == b"body"
assert resp.headers == {"X-Foo": "bar"}
assert opener.requests[0].get_method() == "GET"
assert opener.requests[0].full_url == "http://testserver/page"
def test_get_with_params(self) -> None:
opener = _FakeOpener()
session = HttpSession()
session._opener = opener
session.get("http://testserver/page", params={"a": "1", "b": "x y"})
assert opener.requests[0].full_url == (
"http://testserver/page?a=1&b=x+y"
)
def test_default_headers_sent(self) -> None:
opener = _FakeOpener()
session = HttpSession(default_headers={"User-Agent": "test"})
session._opener = opener
session.get("http://testserver/")
assert opener.requests[0].get_header("User-agent") == "test"
def test_request_headers_override_defaults(self) -> None:
opener = _FakeOpener()
session = HttpSession(default_headers={"X-Token": "default"})
session._opener = opener
session.get("http://testserver/", headers={"X-Token": "override"})
assert opener.requests[0].get_header("X-token") == "override"
def test_post_form_encodes_data(self) -> None:
opener = _FakeOpener()
session = HttpSession()
session._opener = opener
session.post("http://testserver/login", data={"user": "u", "pass": "p"})
req = opener.requests[0]
assert req.get_method() == "POST"
assert isinstance(req.data, bytes)
assert dict(urllib.parse.parse_qsl(req.data.decode())) == {
"user": "u",
"pass": "p",
}
assert req.get_header("Content-type") == (
"application/x-www-form-urlencoded"
)
def test_post_keeps_explicit_content_type(self) -> None:
opener = _FakeOpener()
session = HttpSession()
session._opener = opener
session.post(
"http://testserver/",
data={"a": "1"},
headers={"Content-Type": "text/plain"},
)
assert opener.requests[0].get_header("Content-type") == "text/plain"
def test_post_without_data(self) -> None:
opener = _FakeOpener()
session = HttpSession()
session._opener = opener
session.post("http://testserver/")
assert opener.requests[0].data is None
def test_http_error_returned_as_response(self) -> None:
opener = _FakeOpener([
_http_error(404, b"missing", {"X-Err": "yes"}),
])
session = HttpSession()
session._opener = opener
resp = session.get("http://testserver/nope")
assert resp.status_code == 404
assert resp.data == b"missing"
assert resp.headers["X-Err"] == "yes"

View file

@ -1,133 +0,0 @@
"""Tests for the Forgejo API wrapper."""
import json
from typing import Any, Dict, List, Optional, Tuple
import pytest
from byteb4rb1e.utils.http.client import HttpResponse
from byteb4rb1e.utils.saas import forgejo
HOST = "git.example.com"
class _Recorder:
"""Records http_client calls and replays a canned response."""
def __init__(self, response: HttpResponse) -> None:
self.calls: List[Tuple[str, Dict[str, Any]]] = []
self._response = response
def __call__(self, url: str, **kwargs: Any) -> HttpResponse:
self.calls.append((url, kwargs))
return self._response
class TestApiUrl:
def test_host_only(self) -> None:
assert forgejo.api_url(HOST) == "https://git.example.com/api/v1"
class TestHttpHeaders:
def test_token_header(self) -> None:
headers = forgejo.http_headers("s3cret")
assert headers["Authorization"] == "token s3cret"
assert headers["Accept"] == "application/json"
assert headers["Content-Type"] == "application/json"
class TestRepositoryExists:
def test_exists(self, monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(HttpResponse(200, {}, b"{}"))
monkeypatch.setattr(forgejo.http_client, "get", recorder)
assert forgejo.repository_exists(HOST, "tiara", "repo", "t") is True
url, kwargs = recorder.calls[0]
assert url == "https://git.example.com/api/v1/repos/tiara/repo"
assert kwargs["headers"]["Authorization"] == "token t"
def test_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(HttpResponse(404, {}, b""))
monkeypatch.setattr(forgejo.http_client, "get", recorder)
assert forgejo.repository_exists(HOST, "tiara", "repo", "t") is False
class TestCreateRepository:
def _create(
self,
monkeypatch: pytest.MonkeyPatch,
org: Optional[str] = None,
**kwargs: Any,
) -> _Recorder:
recorder = _Recorder(HttpResponse(201, {}, b"{}"))
monkeypatch.setattr(forgejo.http_client, "post", recorder)
forgejo.create_repository(HOST, "repo", "t", org=org, **kwargs)
return recorder
def test_user_repo_endpoint(
self, monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = self._create(monkeypatch)
url, _ = recorder.calls[0]
assert url == "https://git.example.com/api/v1/user/repos"
def test_org_repo_endpoint(
self, monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = self._create(monkeypatch, org="byteb4rb1e")
url, _ = recorder.calls[0]
assert url == "https://git.example.com/api/v1/orgs/byteb4rb1e/repos"
def test_body(self, monkeypatch: pytest.MonkeyPatch) -> None:
recorder = self._create(
monkeypatch, description="demo", is_private=False,
)
_, kwargs = recorder.calls[0]
body = json.loads(kwargs["data"].decode("utf-8"))
assert body == {
"name": "repo",
"private": False,
"description": "demo",
}
def test_defaults_to_private(
self, monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = self._create(monkeypatch)
_, kwargs = recorder.calls[0]
body = json.loads(kwargs["data"].decode("utf-8"))
assert body["private"] is True
def test_auth_header(self, monkeypatch: pytest.MonkeyPatch) -> None:
recorder = self._create(monkeypatch)
_, kwargs = recorder.calls[0]
assert kwargs["headers"]["Authorization"] == "token t"
def test_returns_response(
self, monkeypatch: pytest.MonkeyPatch,
) -> None:
response = HttpResponse(201, {}, b'{"id": 1}')
recorder = _Recorder(response)
monkeypatch.setattr(forgejo.http_client, "post", recorder)
resp = forgejo.create_repository(HOST, "repo", "t")
assert resp is response
class TestCloneUrls:
def test_ssh(self) -> None:
assert forgejo.ssh_clone_url(HOST, "tiara", "repo") == (
"git@git.example.com:tiara/repo.git"
)
def test_https(self) -> None:
assert forgejo.https_clone_url(HOST, "tiara", "repo") == (
"https://git.example.com/tiara/repo.git"
)

View file

@ -1,60 +0,0 @@
"""Tests for the git subprocess wrapper's URL parsing helpers."""
import pytest
from byteb4rb1e.utils.vcs.git import parse_base_url, parse_repo_name
class TestParseBaseUrl:
def test_bitbucket(self) -> None:
result = parse_base_url("git@bitbucket.org:byteb4rb1e/foo.git")
assert result == "byteb4rb1e"
def test_forgejo_host(self) -> None:
result = parse_base_url(
"git@git.code.tiararodney.com:h5p-mirror/foo.git"
)
assert result == "h5p-mirror"
def test_github_host(self) -> None:
result = parse_base_url("git@github.com:h5p/h5p-multi-choice.git")
assert result == "h5p"
def test_returns_str(self) -> None:
result = parse_base_url("git@bitbucket.org:byteb4rb1e/foo.git")
assert isinstance(result, str)
def test_rejects_https_url(self) -> None:
with pytest.raises(ValueError):
parse_base_url("https://bitbucket.org/byteb4rb1e/foo.git")
def test_rejects_url_without_colon(self) -> None:
with pytest.raises(ValueError):
parse_base_url("bitbucket.org/byteb4rb1e/foo.git")
class TestParseRepoName:
def test_bitbucket(self) -> None:
assert parse_repo_name(
"git@bitbucket.org:byteb4rb1e/foo.git"
) == "foo"
def test_forgejo_host(self) -> None:
assert parse_repo_name(
"git@git.code.tiararodney.com:h5p-mirror/foo.git"
) == "foo"
def test_without_git_suffix(self) -> None:
assert parse_repo_name(
"git@git.code.tiararodney.com:h5p-mirror/foo"
) == "foo"
def test_rejects_https_url(self) -> None:
with pytest.raises(ValueError):
parse_repo_name("https://git.code.tiararodney.com/x/foo.git")
def test_rejects_url_without_colon(self) -> None:
with pytest.raises(ValueError):
parse_repo_name("git.code.tiararodney.com/x/foo.git")

View file

@ -31,9 +31,9 @@ commands =
description = run type check on code base description = run type check on code base
labels = static labels = static
deps = deps =
autopep8 black
commands = commands =
autopep8 --diff --exit-code src tests black --check src tests
[testenv:unit-py3{9-13}] [testenv:unit-py3{9-13}]
description = run type check on code base description = run type check on code base