Compare commits
2 commits
develop
...
feature/17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e215c69c | ||
|
|
db72017810 |
28 changed files with 3388 additions and 3281 deletions
122
DEVELOPMENT.md
122
DEVELOPMENT.md
|
|
@ -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
32
Makefile
Normal 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
20
Pipfile
|
|
@ -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
890
Pipfile.lock
generated
|
|
@ -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
218
TODO
|
|
@ -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.
|
|
||||||
|
|
|
||||||
27
configure.ac
Normal file
27
configure.ac
Normal 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
|
||||||
|
|
@ -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
25
requirements-dev.txt
Normal 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'
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
364
src/byteb4rb1e/utils/http/parser.py
Normal file
364
src/byteb4rb1e/utils/http/parser.py
Normal 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))
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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),
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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)
|
|
||||||
134
tests/unit/byteb4rb1e/utils/http/parser/test_node.py
Normal file
134
tests/unit/byteb4rb1e/utils/http/parser/test_node.py
Normal 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
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
)
|
|
||||||
|
|
@ -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")
|
|
||||||
4
tox.ini
4
tox.ini
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue