content-gateway-client 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- content_gateway_client-0.1.0/.gitignore +31 -0
- content_gateway_client-0.1.0/CHANGELOG.md +5 -0
- content_gateway_client-0.1.0/PKG-INFO +98 -0
- content_gateway_client-0.1.0/README.md +78 -0
- content_gateway_client-0.1.0/pyproject.toml +63 -0
- content_gateway_client-0.1.0/src/content_gateway/__init__.py +73 -0
- content_gateway_client-0.1.0/src/content_gateway/client.py +623 -0
- content_gateway_client-0.1.0/src/content_gateway/generated.py +157 -0
- content_gateway_client-0.1.0/src/content_gateway/py.typed +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
/target
|
|
3
|
+
/build
|
|
4
|
+
|
|
5
|
+
# Node
|
|
6
|
+
node_modules
|
|
7
|
+
.pnpm-store
|
|
8
|
+
*.log
|
|
9
|
+
dist
|
|
10
|
+
*.tsbuildinfo
|
|
11
|
+
|
|
12
|
+
# Python
|
|
13
|
+
.venv
|
|
14
|
+
__pycache__
|
|
15
|
+
*.egg-info
|
|
16
|
+
.pytest_cache
|
|
17
|
+
.mypy_cache
|
|
18
|
+
.ruff_cache
|
|
19
|
+
|
|
20
|
+
# Env
|
|
21
|
+
.env
|
|
22
|
+
.env.*
|
|
23
|
+
!.env.example
|
|
24
|
+
|
|
25
|
+
# Editors and tools
|
|
26
|
+
.vscode/*
|
|
27
|
+
!.vscode/extensions.json
|
|
28
|
+
.idea
|
|
29
|
+
.claude
|
|
30
|
+
.cursor
|
|
31
|
+
.DS_Store
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# content-gateway-client
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release: the gateway's routes typed from its OpenAPI document, a sync and an async client for the CurseForge and Modrinth passthroughs with the gateway's refusals handled, `resolve`, the Minecraft and loader version lists, and `modrinth_file_url`.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: content-gateway-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed client for content-gateway: the gateway's own routes from its OpenAPI document and the CurseForge and Modrinth passthroughs with the budget's refusals handled.
|
|
5
|
+
Project-URL: Repository, https://github.com/matejhozlar/createrington-content-gateway
|
|
6
|
+
Project-URL: Changelog, https://github.com/matejhozlar/createrington-content-gateway/blob/main/packages/python-client/CHANGELOG.md
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: api-client,curseforge,minecraft,modrinth
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Games/Entertainment
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: httpx<1,>=0.28
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# content-gateway-client
|
|
22
|
+
|
|
23
|
+
The Python client of [content-gateway](../../README.md): the gateway's own routes typed from its OpenAPI document, and the CurseForge and Modrinth passthroughs with the gateway's refusals handled. Sync and async, on [httpx](https://www.python-httpx.org/).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install content-gateway-client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Python 3.11 or newer.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from content_gateway import ContentGateway, ContentGatewayError
|
|
35
|
+
|
|
36
|
+
with ContentGateway("http://127.0.0.1:5005", token=TOKEN, pool="app") as gateway:
|
|
37
|
+
answer = gateway.curseforge(
|
|
38
|
+
"mods/search", query={"gameId": 432, "searchFilter": "jei", "classId": 6}
|
|
39
|
+
)
|
|
40
|
+
if not answer.ok:
|
|
41
|
+
raise RuntimeError(f"CurseForge answered {answer.status}")
|
|
42
|
+
for mod in answer.body["data"]:
|
|
43
|
+
print(mod["id"], mod["name"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`curseforge(path, ...)` sends `api.curseforge.com/v1/<path>` through the gateway and answers whatever CurseForge answered as a `PassthroughAnswer`: `status`, `ok`, `body` (parsed JSON, the text when it is not JSON, `None` when empty), `headers`, and `cache`, which is `hit`, `miss` or `stale` from the gateway's `X-Cache`. A `body` makes the request a `POST`; `fresh=True` asks CurseForge again instead of the cache; `pool` draws from another of the token's pools. `query` values of `None` are left out.
|
|
47
|
+
|
|
48
|
+
Only the gateway's own answers raise, as `ContentGatewayError` with the gateway's `code` (`BUDGET_SPENT`, `UPSTREAM_HOLD`, `UNAUTHORIZED`, ...), `status`, `pool` and `retry_after_ms`. `code` is `None` when the answer was not the gateway's JSON error at all, such as a proxy's HTML page.
|
|
49
|
+
|
|
50
|
+
`modrinth(path, ...)` sends `api.modrinth.com/v2/<path>` through the gateway the same way; the calls draw from the pool's Modrinth budget. `modrinth_version_file(sha1)` and `modrinth_version_files(hashes)` are the two SHA-1 lookups, and `modrinth_file_url(version, sha1)` picks the address of the file itself on Modrinth's CDN out of a version.
|
|
51
|
+
|
|
52
|
+
`resolve(file_ids, cdn=True, pool=None, fresh=False, wait_up_to=None, timeout=60)` reads `/v1/resolve`: a download address per CurseForge file, from CurseForge's own link, else Modrinth's copy of the file found by SHA-1, else the file's built address on CurseForge's CDN when it answers a HEAD with the file's size (`cdn=False` skips that step). A file with a `source` of `None` and `retry` true could not be fully checked this time. The consumer downloads from the address itself; no bytes pass through the gateway. The gateway tries the three sources in a row, so a call can take 40 s at worst; `resolve()` therefore waits 60 s rather than the client's timeout.
|
|
53
|
+
|
|
54
|
+
`minecraft_versions(type=None, loader=None)`, `minecraft_version(id)` and `loader_versions(loader, minecraft=None)` read `/v1/minecraft/*`: Mojang's version list (releases by default, narrowed to what a loader builds for), one version with the Java major it needs, and a loader's builds (NeoForge, Forge, Fabric) with `stable` and `recommended` flags.
|
|
55
|
+
|
|
56
|
+
`health()` reads `/v1/healthz` and `budget(days=None)` reads `/v1/budget`, one `UpstreamBudget` for CurseForge and one for Modrinth with the last `days` UTC days of counts (14 when left out, more than the gateway's `historyDays` answers the days kept).
|
|
57
|
+
|
|
58
|
+
The gateway's own answers are plain dicts typed as `TypedDict`s generated from `openapi.json` (`Health`, `BudgetSnapshot`, `Resolved`, `MinecraftVersions`, ...), so a type checker knows their keys. The passthrough bodies are CurseForge's and Modrinth's JSON as it came, typed `Any`.
|
|
59
|
+
|
|
60
|
+
### Async
|
|
61
|
+
|
|
62
|
+
`AsyncContentGateway` has the same methods as coroutines, on `httpx.AsyncClient`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from content_gateway import AsyncContentGateway
|
|
66
|
+
|
|
67
|
+
async with AsyncContentGateway("http://127.0.0.1:5005", token=TOKEN) as gateway:
|
|
68
|
+
health = await gateway.health()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Waiting out a refusal
|
|
72
|
+
|
|
73
|
+
A refused call answers `503` with `Retry-After`. A background job can sleep it out:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
answer = gateway.curseforge("mods", body={"modIds": mod_ids}, wait_up_to=300)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The call is sent again after each refusal that fits in what is left of `wait_up_to` seconds; one that does not fit raises at once. A request answering a user should leave it unset and pass the `retry_after_ms` on.
|
|
80
|
+
|
|
81
|
+
### Timeouts and connections
|
|
82
|
+
|
|
83
|
+
`timeout`, 15 s by default, is httpx's limit on each phase of a request (connecting, waiting for a read, a write), not on the whole call; the gateway answers in one body, so a call ends within it in practice. The gateway gives CurseForge 10 s before it answers `504` or a stale answer, so keep the client's timeout above that: a stalled call then comes back as the gateway's answer instead of an `httpx.TimeoutException` here. The client keeps connections to the gateway open between calls; pass your own `httpx.Client` or `httpx.AsyncClient` as `client` to change that or to test (an `httpx.MockTransport` makes a fake gateway).
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv sync # the virtual environment with the dev tools
|
|
89
|
+
uv run pytest
|
|
90
|
+
uv run mypy
|
|
91
|
+
uv run ruff check . && uv run ruff format --check .
|
|
92
|
+
uv run python scripts/generate.py # rewrite src/content_gateway/generated.py from openapi.json
|
|
93
|
+
uv run python scripts/generate.py --check # fail when it is stale
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`src/content_gateway/generated.py` is generated from the gateway's `openapi.json` with [datamodel-code-generator](https://koxudaxi.github.io/datamodel-code-generator/) and checked in; `pnpm openapi` at the repository root regenerates it with the TypeScript types, and CI fails when it is stale. Never edit it by hand.
|
|
97
|
+
|
|
98
|
+
The version lives in `pyproject.toml` and `CHANGELOG.md` holds its entries. A release tag of the gateway publishes the package to PyPI when that version is not there yet, through PyPI's trusted publishing from the release workflow, so the PyPI project trusts `release.yml` of the GitHub repository and no token is stored anywhere.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# content-gateway-client
|
|
2
|
+
|
|
3
|
+
The Python client of [content-gateway](../../README.md): the gateway's own routes typed from its OpenAPI document, and the CurseForge and Modrinth passthroughs with the gateway's refusals handled. Sync and async, on [httpx](https://www.python-httpx.org/).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install content-gateway-client
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Python 3.11 or newer.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from content_gateway import ContentGateway, ContentGatewayError
|
|
15
|
+
|
|
16
|
+
with ContentGateway("http://127.0.0.1:5005", token=TOKEN, pool="app") as gateway:
|
|
17
|
+
answer = gateway.curseforge(
|
|
18
|
+
"mods/search", query={"gameId": 432, "searchFilter": "jei", "classId": 6}
|
|
19
|
+
)
|
|
20
|
+
if not answer.ok:
|
|
21
|
+
raise RuntimeError(f"CurseForge answered {answer.status}")
|
|
22
|
+
for mod in answer.body["data"]:
|
|
23
|
+
print(mod["id"], mod["name"])
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`curseforge(path, ...)` sends `api.curseforge.com/v1/<path>` through the gateway and answers whatever CurseForge answered as a `PassthroughAnswer`: `status`, `ok`, `body` (parsed JSON, the text when it is not JSON, `None` when empty), `headers`, and `cache`, which is `hit`, `miss` or `stale` from the gateway's `X-Cache`. A `body` makes the request a `POST`; `fresh=True` asks CurseForge again instead of the cache; `pool` draws from another of the token's pools. `query` values of `None` are left out.
|
|
27
|
+
|
|
28
|
+
Only the gateway's own answers raise, as `ContentGatewayError` with the gateway's `code` (`BUDGET_SPENT`, `UPSTREAM_HOLD`, `UNAUTHORIZED`, ...), `status`, `pool` and `retry_after_ms`. `code` is `None` when the answer was not the gateway's JSON error at all, such as a proxy's HTML page.
|
|
29
|
+
|
|
30
|
+
`modrinth(path, ...)` sends `api.modrinth.com/v2/<path>` through the gateway the same way; the calls draw from the pool's Modrinth budget. `modrinth_version_file(sha1)` and `modrinth_version_files(hashes)` are the two SHA-1 lookups, and `modrinth_file_url(version, sha1)` picks the address of the file itself on Modrinth's CDN out of a version.
|
|
31
|
+
|
|
32
|
+
`resolve(file_ids, cdn=True, pool=None, fresh=False, wait_up_to=None, timeout=60)` reads `/v1/resolve`: a download address per CurseForge file, from CurseForge's own link, else Modrinth's copy of the file found by SHA-1, else the file's built address on CurseForge's CDN when it answers a HEAD with the file's size (`cdn=False` skips that step). A file with a `source` of `None` and `retry` true could not be fully checked this time. The consumer downloads from the address itself; no bytes pass through the gateway. The gateway tries the three sources in a row, so a call can take 40 s at worst; `resolve()` therefore waits 60 s rather than the client's timeout.
|
|
33
|
+
|
|
34
|
+
`minecraft_versions(type=None, loader=None)`, `minecraft_version(id)` and `loader_versions(loader, minecraft=None)` read `/v1/minecraft/*`: Mojang's version list (releases by default, narrowed to what a loader builds for), one version with the Java major it needs, and a loader's builds (NeoForge, Forge, Fabric) with `stable` and `recommended` flags.
|
|
35
|
+
|
|
36
|
+
`health()` reads `/v1/healthz` and `budget(days=None)` reads `/v1/budget`, one `UpstreamBudget` for CurseForge and one for Modrinth with the last `days` UTC days of counts (14 when left out, more than the gateway's `historyDays` answers the days kept).
|
|
37
|
+
|
|
38
|
+
The gateway's own answers are plain dicts typed as `TypedDict`s generated from `openapi.json` (`Health`, `BudgetSnapshot`, `Resolved`, `MinecraftVersions`, ...), so a type checker knows their keys. The passthrough bodies are CurseForge's and Modrinth's JSON as it came, typed `Any`.
|
|
39
|
+
|
|
40
|
+
### Async
|
|
41
|
+
|
|
42
|
+
`AsyncContentGateway` has the same methods as coroutines, on `httpx.AsyncClient`:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from content_gateway import AsyncContentGateway
|
|
46
|
+
|
|
47
|
+
async with AsyncContentGateway("http://127.0.0.1:5005", token=TOKEN) as gateway:
|
|
48
|
+
health = await gateway.health()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Waiting out a refusal
|
|
52
|
+
|
|
53
|
+
A refused call answers `503` with `Retry-After`. A background job can sleep it out:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
answer = gateway.curseforge("mods", body={"modIds": mod_ids}, wait_up_to=300)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The call is sent again after each refusal that fits in what is left of `wait_up_to` seconds; one that does not fit raises at once. A request answering a user should leave it unset and pass the `retry_after_ms` on.
|
|
60
|
+
|
|
61
|
+
### Timeouts and connections
|
|
62
|
+
|
|
63
|
+
`timeout`, 15 s by default, is httpx's limit on each phase of a request (connecting, waiting for a read, a write), not on the whole call; the gateway answers in one body, so a call ends within it in practice. The gateway gives CurseForge 10 s before it answers `504` or a stale answer, so keep the client's timeout above that: a stalled call then comes back as the gateway's answer instead of an `httpx.TimeoutException` here. The client keeps connections to the gateway open between calls; pass your own `httpx.Client` or `httpx.AsyncClient` as `client` to change that or to test (an `httpx.MockTransport` makes a fake gateway).
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv sync # the virtual environment with the dev tools
|
|
69
|
+
uv run pytest
|
|
70
|
+
uv run mypy
|
|
71
|
+
uv run ruff check . && uv run ruff format --check .
|
|
72
|
+
uv run python scripts/generate.py # rewrite src/content_gateway/generated.py from openapi.json
|
|
73
|
+
uv run python scripts/generate.py --check # fail when it is stale
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`src/content_gateway/generated.py` is generated from the gateway's `openapi.json` with [datamodel-code-generator](https://koxudaxi.github.io/datamodel-code-generator/) and checked in; `pnpm openapi` at the repository root regenerates it with the TypeScript types, and CI fails when it is stale. Never edit it by hand.
|
|
77
|
+
|
|
78
|
+
The version lives in `pyproject.toml` and `CHANGELOG.md` holds its entries. A release tag of the gateway publishes the package to PyPI when that version is not there yet, through PyPI's trusted publishing from the release workflow, so the PyPI project trusts `release.yml` of the GitHub repository and no token is stored anywhere.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "content-gateway-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typed client for content-gateway: the gateway's own routes from its OpenAPI document and the CurseForge and Modrinth passthroughs with the budget's refusals handled."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = ["httpx>=0.28,<1"]
|
|
9
|
+
keywords = ["curseforge", "modrinth", "minecraft", "api-client"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Topic :: Games/Entertainment",
|
|
17
|
+
"Topic :: Software Development :: Libraries",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/matejhozlar/createrington-content-gateway"
|
|
23
|
+
Changelog = "https://github.com/matejhozlar/createrington-content-gateway/blob/main/packages/python-client/CHANGELOG.md"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling>=1.33"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/content_gateway"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["src/content_gateway", "README.md", "CHANGELOG.md"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"anyio>=4.12",
|
|
38
|
+
"datamodel-code-generator>=0.89",
|
|
39
|
+
"mypy>=2.4",
|
|
40
|
+
"pytest>=9.2",
|
|
41
|
+
"ruff>=0.17",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.uv]
|
|
45
|
+
required-version = ">=0.13.7"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py311"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF", "ANN", "PT"]
|
|
53
|
+
ignore = ["ANN401", "E501"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint.per-file-ignores]
|
|
56
|
+
"src/content_gateway/generated.py" = ["I001"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
strict = true
|
|
60
|
+
files = ["src", "tests", "scripts"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
DEFAULT_TIMEOUT,
|
|
3
|
+
MODRINTH_CDN_HOST,
|
|
4
|
+
RESOLVE_TIMEOUT,
|
|
5
|
+
AsyncContentGateway,
|
|
6
|
+
CacheStatus,
|
|
7
|
+
ContentGateway,
|
|
8
|
+
ContentGatewayError,
|
|
9
|
+
Method,
|
|
10
|
+
PassthroughAnswer,
|
|
11
|
+
Query,
|
|
12
|
+
modrinth_file_url,
|
|
13
|
+
)
|
|
14
|
+
from .generated import (
|
|
15
|
+
ApiError,
|
|
16
|
+
BudgetSnapshot,
|
|
17
|
+
DayCounts,
|
|
18
|
+
DayPoolSnapshot,
|
|
19
|
+
DaySnapshot,
|
|
20
|
+
ErrorCode,
|
|
21
|
+
FileSource,
|
|
22
|
+
Health,
|
|
23
|
+
Latest,
|
|
24
|
+
Loader,
|
|
25
|
+
LoaderVersion,
|
|
26
|
+
LoaderVersions,
|
|
27
|
+
MinecraftVersion,
|
|
28
|
+
MinecraftVersionDetails,
|
|
29
|
+
MinecraftVersions,
|
|
30
|
+
PoolSnapshot,
|
|
31
|
+
Resolved,
|
|
32
|
+
ResolvedFile,
|
|
33
|
+
Source,
|
|
34
|
+
UpstreamBudget,
|
|
35
|
+
VersionFilter,
|
|
36
|
+
VersionType,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DEFAULT_TIMEOUT",
|
|
41
|
+
"MODRINTH_CDN_HOST",
|
|
42
|
+
"RESOLVE_TIMEOUT",
|
|
43
|
+
"ApiError",
|
|
44
|
+
"AsyncContentGateway",
|
|
45
|
+
"BudgetSnapshot",
|
|
46
|
+
"CacheStatus",
|
|
47
|
+
"ContentGateway",
|
|
48
|
+
"ContentGatewayError",
|
|
49
|
+
"DayCounts",
|
|
50
|
+
"DayPoolSnapshot",
|
|
51
|
+
"DaySnapshot",
|
|
52
|
+
"ErrorCode",
|
|
53
|
+
"FileSource",
|
|
54
|
+
"Health",
|
|
55
|
+
"Latest",
|
|
56
|
+
"Loader",
|
|
57
|
+
"LoaderVersion",
|
|
58
|
+
"LoaderVersions",
|
|
59
|
+
"Method",
|
|
60
|
+
"MinecraftVersion",
|
|
61
|
+
"MinecraftVersionDetails",
|
|
62
|
+
"MinecraftVersions",
|
|
63
|
+
"PassthroughAnswer",
|
|
64
|
+
"PoolSnapshot",
|
|
65
|
+
"Query",
|
|
66
|
+
"Resolved",
|
|
67
|
+
"ResolvedFile",
|
|
68
|
+
"Source",
|
|
69
|
+
"UpstreamBudget",
|
|
70
|
+
"VersionFilter",
|
|
71
|
+
"VersionType",
|
|
72
|
+
"modrinth_file_url",
|
|
73
|
+
]
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal, cast
|
|
10
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .generated import (
|
|
15
|
+
ApiError,
|
|
16
|
+
BudgetSnapshot,
|
|
17
|
+
ErrorCode,
|
|
18
|
+
Health,
|
|
19
|
+
Loader,
|
|
20
|
+
LoaderVersions,
|
|
21
|
+
MinecraftVersionDetails,
|
|
22
|
+
MinecraftVersions,
|
|
23
|
+
Resolved,
|
|
24
|
+
VersionFilter,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DEFAULT_TIMEOUT = 15.0
|
|
28
|
+
"""How long each phase of a request to the gateway may take (connecting, a read, a write), in seconds, above the 10 s it gives an upstream."""
|
|
29
|
+
|
|
30
|
+
RESOLVE_TIMEOUT = 60.0
|
|
31
|
+
"""The same for `resolve()` unless told otherwise, above the gateway's 40 s worst case."""
|
|
32
|
+
|
|
33
|
+
MODRINTH_CDN_HOST = "cdn.modrinth.com"
|
|
34
|
+
"""The host every Modrinth download address is on."""
|
|
35
|
+
|
|
36
|
+
CacheStatus = Literal["hit", "miss", "stale"]
|
|
37
|
+
"""How the gateway answered a passthrough call, from `X-Cache`."""
|
|
38
|
+
|
|
39
|
+
Query = Mapping[str, str | int | float | bool | None]
|
|
40
|
+
"""Query parameters; `None` values are left out and booleans become `true` or `false`."""
|
|
41
|
+
|
|
42
|
+
Method = Literal["GET", "POST"]
|
|
43
|
+
|
|
44
|
+
_CACHE_HEADER = "x-cache"
|
|
45
|
+
_POOL_HEADER = "x-budget-pool"
|
|
46
|
+
_CACHE_STATUSES: tuple[CacheStatus, ...] = ("hit", "miss", "stale")
|
|
47
|
+
_JSON_TYPE = re.compile(r"\bjson\b", re.IGNORECASE)
|
|
48
|
+
_SECONDS = re.compile(r"^\d+$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class PassthroughAnswer:
|
|
53
|
+
"""What an upstream (CurseForge or Modrinth) answered through the gateway."""
|
|
54
|
+
|
|
55
|
+
status: int
|
|
56
|
+
ok: bool
|
|
57
|
+
cache: CacheStatus
|
|
58
|
+
body: Any
|
|
59
|
+
headers: httpx.Headers
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ContentGatewayError(Exception):
|
|
63
|
+
"""An answer the gateway gave itself instead of passing one on; `code` is `None` when it was not the gateway's JSON error."""
|
|
64
|
+
|
|
65
|
+
status: int
|
|
66
|
+
code: ErrorCode | None
|
|
67
|
+
pool: str | None
|
|
68
|
+
retry_after_ms: int | None
|
|
69
|
+
message: str
|
|
70
|
+
|
|
71
|
+
def __init__(self, status: int, error: ApiError | None, fallback: str) -> None:
|
|
72
|
+
super().__init__(status, error, fallback)
|
|
73
|
+
self.message = error["message"] if error is not None else fallback
|
|
74
|
+
self.status = status
|
|
75
|
+
self.code = error["code"] if error is not None else None
|
|
76
|
+
self.pool = error.get("pool") if error is not None else None
|
|
77
|
+
self.retry_after_ms = error.get("retryAfterMs") if error is not None else None
|
|
78
|
+
|
|
79
|
+
def __str__(self) -> str:
|
|
80
|
+
return self.message
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class _Request:
|
|
85
|
+
method: Method
|
|
86
|
+
path: str
|
|
87
|
+
query: Query | None = None
|
|
88
|
+
body: Any = None
|
|
89
|
+
authenticated: bool = True
|
|
90
|
+
pool: str | None = None
|
|
91
|
+
fresh: bool = False
|
|
92
|
+
wait_up_to: float | None = None
|
|
93
|
+
timeout: float | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_Outcome = tuple[Literal["done"], Any] | tuple[Literal["wait"], float]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Gateway:
|
|
100
|
+
def __init__(self, base_url: str, token: str, pool: str | None, timeout: float) -> None:
|
|
101
|
+
self._base_url = base_url.rstrip("/")
|
|
102
|
+
self._token = token
|
|
103
|
+
self._pool = pool
|
|
104
|
+
self._timeout = timeout
|
|
105
|
+
|
|
106
|
+
def _build(self, request: _Request) -> tuple[str, dict[str, str], bytes | None]:
|
|
107
|
+
url = self._base_url + request.path + _query_string(request.query)
|
|
108
|
+
headers = {"accept": "application/json"}
|
|
109
|
+
if request.authenticated:
|
|
110
|
+
headers["authorization"] = f"Bearer {self._token}"
|
|
111
|
+
pool = request.pool if request.pool is not None else self._pool
|
|
112
|
+
if pool is not None:
|
|
113
|
+
headers[_POOL_HEADER] = pool
|
|
114
|
+
if request.fresh:
|
|
115
|
+
headers["cache-control"] = "no-cache"
|
|
116
|
+
content: bytes | None = None
|
|
117
|
+
if request.body is not None:
|
|
118
|
+
headers["content-type"] = "application/json"
|
|
119
|
+
content = json.dumps(request.body).encode()
|
|
120
|
+
return url, headers, content
|
|
121
|
+
|
|
122
|
+
def _settle(self, request: _Request, response: httpx.Response, deadline: float) -> _Outcome:
|
|
123
|
+
body = _read_body(response)
|
|
124
|
+
if _CACHE_HEADER in response.headers or response.is_success:
|
|
125
|
+
return ("done", body)
|
|
126
|
+
error = _as_api_error(body)
|
|
127
|
+
wait = _retry_after(response, error)
|
|
128
|
+
if response.status_code == 503 and wait is not None and time.monotonic() + wait <= deadline:
|
|
129
|
+
return ("wait", wait)
|
|
130
|
+
fallback = f"the gateway answered {response.status_code} to {request.method} {request.path}"
|
|
131
|
+
if isinstance(body, str) and body != "":
|
|
132
|
+
fallback += f": {body}"
|
|
133
|
+
raise ContentGatewayError(response.status_code, error, fallback)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _passthrough_answer(
|
|
137
|
+
request: _Request, response: httpx.Response, body: Any
|
|
138
|
+
) -> PassthroughAnswer:
|
|
139
|
+
cache = response.headers.get(_CACHE_HEADER)
|
|
140
|
+
if cache not in _CACHE_STATUSES:
|
|
141
|
+
raise ContentGatewayError(
|
|
142
|
+
response.status_code,
|
|
143
|
+
None,
|
|
144
|
+
f"the answer to {request.method} {request.path} did not come through the gateway: "
|
|
145
|
+
"no X-Cache header",
|
|
146
|
+
)
|
|
147
|
+
return PassthroughAnswer(
|
|
148
|
+
status=response.status_code,
|
|
149
|
+
ok=response.is_success,
|
|
150
|
+
cache=cast(CacheStatus, cache),
|
|
151
|
+
body=body,
|
|
152
|
+
headers=response.headers,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _passthrough_request(
|
|
157
|
+
upstream: str,
|
|
158
|
+
path: str,
|
|
159
|
+
*,
|
|
160
|
+
query: Query | None,
|
|
161
|
+
body: Any,
|
|
162
|
+
method: Method | None,
|
|
163
|
+
pool: str | None,
|
|
164
|
+
fresh: bool,
|
|
165
|
+
wait_up_to: float | None,
|
|
166
|
+
) -> _Request:
|
|
167
|
+
chosen: Method = method if method is not None else ("GET" if body is None else "POST")
|
|
168
|
+
if chosen == "GET" and body is not None:
|
|
169
|
+
raise TypeError(
|
|
170
|
+
f"a GET to {path} cannot carry a body; leave method unset or make it POST"
|
|
171
|
+
)
|
|
172
|
+
return _Request(
|
|
173
|
+
method=chosen,
|
|
174
|
+
path=f"/v1/{upstream}/{path.lstrip('/')}",
|
|
175
|
+
query=query,
|
|
176
|
+
body=body,
|
|
177
|
+
pool=pool,
|
|
178
|
+
fresh=fresh,
|
|
179
|
+
wait_up_to=wait_up_to,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _budget_request(days: int | None) -> _Request:
|
|
184
|
+
return _Request("GET", "/v1/budget", query=None if days is None else {"days": days})
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _resolve_request(
|
|
188
|
+
file_ids: Sequence[int],
|
|
189
|
+
*,
|
|
190
|
+
cdn: bool | None,
|
|
191
|
+
pool: str | None,
|
|
192
|
+
fresh: bool,
|
|
193
|
+
wait_up_to: float | None,
|
|
194
|
+
timeout: float,
|
|
195
|
+
) -> _Request:
|
|
196
|
+
body: dict[str, Any] = {"fileIds": list(file_ids)}
|
|
197
|
+
if cdn is not None:
|
|
198
|
+
body["cdn"] = cdn
|
|
199
|
+
return _Request(
|
|
200
|
+
"POST",
|
|
201
|
+
"/v1/resolve",
|
|
202
|
+
body=body,
|
|
203
|
+
pool=pool,
|
|
204
|
+
fresh=fresh,
|
|
205
|
+
wait_up_to=wait_up_to,
|
|
206
|
+
timeout=timeout,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _minecraft_versions_request(type: VersionFilter | None, loader: Loader | None) -> _Request:
|
|
211
|
+
return _Request("GET", "/v1/minecraft/versions", query={"type": type, "loader": loader})
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _minecraft_version_request(id: str) -> _Request:
|
|
215
|
+
return _Request("GET", f"/v1/minecraft/versions/{quote(id, safe='')}")
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _loader_versions_request(loader: Loader, minecraft: str | None) -> _Request:
|
|
219
|
+
return _Request(
|
|
220
|
+
"GET", f"/v1/minecraft/loaders/{loader}/versions", query={"minecraft": minecraft}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ContentGateway(_Gateway):
|
|
225
|
+
"""A consumer's connection to one gateway."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
base_url: str,
|
|
230
|
+
*,
|
|
231
|
+
token: str,
|
|
232
|
+
pool: str | None = None,
|
|
233
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
234
|
+
client: httpx.Client | None = None,
|
|
235
|
+
) -> None:
|
|
236
|
+
super().__init__(base_url, token, pool, timeout)
|
|
237
|
+
self._client = client if client is not None else httpx.Client()
|
|
238
|
+
self._owns_client = client is None
|
|
239
|
+
|
|
240
|
+
def close(self) -> None:
|
|
241
|
+
"""Closes the connections, unless the `httpx.Client` was passed in."""
|
|
242
|
+
if self._owns_client:
|
|
243
|
+
self._client.close()
|
|
244
|
+
|
|
245
|
+
def __enter__(self) -> ContentGateway:
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
def __exit__(self, *exc: object) -> None:
|
|
249
|
+
self.close()
|
|
250
|
+
|
|
251
|
+
def health(self) -> Health:
|
|
252
|
+
"""`GET /v1/healthz`, which needs no token."""
|
|
253
|
+
return cast(Health, self._send(_Request("GET", "/v1/healthz", authenticated=False))[1])
|
|
254
|
+
|
|
255
|
+
def budget(self, *, days: int | None = None) -> BudgetSnapshot:
|
|
256
|
+
"""`GET /v1/budget`: this minute's calls, the 429 hold and the daily counts of the last `days` UTC days."""
|
|
257
|
+
return cast(BudgetSnapshot, self._send(self._budget_request(days))[1])
|
|
258
|
+
|
|
259
|
+
def curseforge(
|
|
260
|
+
self,
|
|
261
|
+
path: str,
|
|
262
|
+
*,
|
|
263
|
+
query: Query | None = None,
|
|
264
|
+
body: Any = None,
|
|
265
|
+
method: Method | None = None,
|
|
266
|
+
pool: str | None = None,
|
|
267
|
+
fresh: bool = False,
|
|
268
|
+
wait_up_to: float | None = None,
|
|
269
|
+
) -> PassthroughAnswer:
|
|
270
|
+
"""Sends `api.curseforge.com/v1/<path>` (such as `mods/search`) through the gateway; CurseForge's answer comes back whatever its status, and only the gateway's own answers raise `ContentGatewayError`."""
|
|
271
|
+
return self._passthrough(
|
|
272
|
+
self._passthrough_request(
|
|
273
|
+
"curseforge",
|
|
274
|
+
path,
|
|
275
|
+
query=query,
|
|
276
|
+
body=body,
|
|
277
|
+
method=method,
|
|
278
|
+
pool=pool,
|
|
279
|
+
fresh=fresh,
|
|
280
|
+
wait_up_to=wait_up_to,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def modrinth(
|
|
285
|
+
self,
|
|
286
|
+
path: str,
|
|
287
|
+
*,
|
|
288
|
+
query: Query | None = None,
|
|
289
|
+
body: Any = None,
|
|
290
|
+
method: Method | None = None,
|
|
291
|
+
pool: str | None = None,
|
|
292
|
+
fresh: bool = False,
|
|
293
|
+
wait_up_to: float | None = None,
|
|
294
|
+
) -> PassthroughAnswer:
|
|
295
|
+
"""Sends `api.modrinth.com/v2/<path>` (such as `project/sodium`) through the gateway; Modrinth's answer comes back whatever its status, and only the gateway's own answers raise `ContentGatewayError`."""
|
|
296
|
+
return self._passthrough(
|
|
297
|
+
self._passthrough_request(
|
|
298
|
+
"modrinth",
|
|
299
|
+
path,
|
|
300
|
+
query=query,
|
|
301
|
+
body=body,
|
|
302
|
+
method=method,
|
|
303
|
+
pool=pool,
|
|
304
|
+
fresh=fresh,
|
|
305
|
+
wait_up_to=wait_up_to,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def modrinth_version_file(
|
|
310
|
+
self,
|
|
311
|
+
sha1: str,
|
|
312
|
+
*,
|
|
313
|
+
pool: str | None = None,
|
|
314
|
+
fresh: bool = False,
|
|
315
|
+
wait_up_to: float | None = None,
|
|
316
|
+
) -> PassthroughAnswer:
|
|
317
|
+
"""`GET /v1/modrinth/version_file/<sha1>`: the version Modrinth lists the file under, or a 404."""
|
|
318
|
+
return self.modrinth(
|
|
319
|
+
f"version_file/{quote(sha1, safe='')}", pool=pool, fresh=fresh, wait_up_to=wait_up_to
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def modrinth_version_files(
|
|
323
|
+
self,
|
|
324
|
+
hashes: Sequence[str],
|
|
325
|
+
*,
|
|
326
|
+
pool: str | None = None,
|
|
327
|
+
fresh: bool = False,
|
|
328
|
+
wait_up_to: float | None = None,
|
|
329
|
+
) -> PassthroughAnswer:
|
|
330
|
+
"""`POST /v1/modrinth/version_files`: the versions Modrinth lists these SHA-1s under, keyed by lowercase hash."""
|
|
331
|
+
return self.modrinth(
|
|
332
|
+
"version_files",
|
|
333
|
+
body={"hashes": list(hashes), "algorithm": "sha1"},
|
|
334
|
+
pool=pool,
|
|
335
|
+
fresh=fresh,
|
|
336
|
+
wait_up_to=wait_up_to,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def resolve(
|
|
340
|
+
self,
|
|
341
|
+
file_ids: Sequence[int],
|
|
342
|
+
*,
|
|
343
|
+
cdn: bool | None = None,
|
|
344
|
+
pool: str | None = None,
|
|
345
|
+
fresh: bool = False,
|
|
346
|
+
wait_up_to: float | None = None,
|
|
347
|
+
timeout: float = RESOLVE_TIMEOUT,
|
|
348
|
+
) -> Resolved:
|
|
349
|
+
"""`POST /v1/resolve`: a download address per CurseForge file id."""
|
|
350
|
+
request = self._resolve_request(
|
|
351
|
+
file_ids, cdn=cdn, pool=pool, fresh=fresh, wait_up_to=wait_up_to, timeout=timeout
|
|
352
|
+
)
|
|
353
|
+
return cast(Resolved, self._send(request)[1])
|
|
354
|
+
|
|
355
|
+
def minecraft_versions(
|
|
356
|
+
self, *, type: VersionFilter | None = None, loader: Loader | None = None
|
|
357
|
+
) -> MinecraftVersions:
|
|
358
|
+
"""`GET /v1/minecraft/versions`: the Minecraft versions Mojang lists, newest first."""
|
|
359
|
+
return cast(
|
|
360
|
+
MinecraftVersions, self._send(self._minecraft_versions_request(type, loader))[1]
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def minecraft_version(self, id: str) -> MinecraftVersionDetails:
|
|
364
|
+
"""`GET /v1/minecraft/versions/<id>`: one version with the Java major it needs; `UNKNOWN_VERSION` raises."""
|
|
365
|
+
return cast(MinecraftVersionDetails, self._send(self._minecraft_version_request(id))[1])
|
|
366
|
+
|
|
367
|
+
def loader_versions(self, loader: Loader, *, minecraft: str | None = None) -> LoaderVersions:
|
|
368
|
+
"""`GET /v1/minecraft/loaders/<loader>/versions`: a loader's builds, newest first."""
|
|
369
|
+
return cast(LoaderVersions, self._send(self._loader_versions_request(loader, minecraft))[1])
|
|
370
|
+
|
|
371
|
+
def _passthrough(self, request: _Request) -> PassthroughAnswer:
|
|
372
|
+
response, body = self._send(request)
|
|
373
|
+
return self._passthrough_answer(request, response, body)
|
|
374
|
+
|
|
375
|
+
def _send(self, request: _Request) -> tuple[httpx.Response, Any]:
|
|
376
|
+
url, headers, content = self._build(request)
|
|
377
|
+
deadline = time.monotonic() + (request.wait_up_to or 0)
|
|
378
|
+
timeout = request.timeout if request.timeout is not None else self._timeout
|
|
379
|
+
while True:
|
|
380
|
+
response = self._client.request(
|
|
381
|
+
request.method, url, headers=headers, content=content, timeout=timeout
|
|
382
|
+
)
|
|
383
|
+
outcome = self._settle(request, response, deadline)
|
|
384
|
+
if outcome[0] == "done":
|
|
385
|
+
return response, outcome[1]
|
|
386
|
+
time.sleep(outcome[1])
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class AsyncContentGateway(_Gateway):
|
|
390
|
+
"""A consumer's connection to one gateway, for asyncio."""
|
|
391
|
+
|
|
392
|
+
def __init__(
|
|
393
|
+
self,
|
|
394
|
+
base_url: str,
|
|
395
|
+
*,
|
|
396
|
+
token: str,
|
|
397
|
+
pool: str | None = None,
|
|
398
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
399
|
+
client: httpx.AsyncClient | None = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
super().__init__(base_url, token, pool, timeout)
|
|
402
|
+
self._client = client if client is not None else httpx.AsyncClient()
|
|
403
|
+
self._owns_client = client is None
|
|
404
|
+
|
|
405
|
+
async def aclose(self) -> None:
|
|
406
|
+
"""Closes the connections, unless the `httpx.AsyncClient` was passed in."""
|
|
407
|
+
if self._owns_client:
|
|
408
|
+
await self._client.aclose()
|
|
409
|
+
|
|
410
|
+
async def __aenter__(self) -> AsyncContentGateway:
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
414
|
+
await self.aclose()
|
|
415
|
+
|
|
416
|
+
async def health(self) -> Health:
|
|
417
|
+
"""`GET /v1/healthz`, which needs no token."""
|
|
418
|
+
sent = await self._send(_Request("GET", "/v1/healthz", authenticated=False))
|
|
419
|
+
return cast(Health, sent[1])
|
|
420
|
+
|
|
421
|
+
async def budget(self, *, days: int | None = None) -> BudgetSnapshot:
|
|
422
|
+
"""`GET /v1/budget`: this minute's calls, the 429 hold and the daily counts of the last `days` UTC days."""
|
|
423
|
+
sent = await self._send(self._budget_request(days))
|
|
424
|
+
return cast(BudgetSnapshot, sent[1])
|
|
425
|
+
|
|
426
|
+
async def curseforge(
|
|
427
|
+
self,
|
|
428
|
+
path: str,
|
|
429
|
+
*,
|
|
430
|
+
query: Query | None = None,
|
|
431
|
+
body: Any = None,
|
|
432
|
+
method: Method | None = None,
|
|
433
|
+
pool: str | None = None,
|
|
434
|
+
fresh: bool = False,
|
|
435
|
+
wait_up_to: float | None = None,
|
|
436
|
+
) -> PassthroughAnswer:
|
|
437
|
+
"""Sends `api.curseforge.com/v1/<path>` (such as `mods/search`) through the gateway; CurseForge's answer comes back whatever its status, and only the gateway's own answers raise `ContentGatewayError`."""
|
|
438
|
+
return await self._passthrough(
|
|
439
|
+
self._passthrough_request(
|
|
440
|
+
"curseforge",
|
|
441
|
+
path,
|
|
442
|
+
query=query,
|
|
443
|
+
body=body,
|
|
444
|
+
method=method,
|
|
445
|
+
pool=pool,
|
|
446
|
+
fresh=fresh,
|
|
447
|
+
wait_up_to=wait_up_to,
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
async def modrinth(
|
|
452
|
+
self,
|
|
453
|
+
path: str,
|
|
454
|
+
*,
|
|
455
|
+
query: Query | None = None,
|
|
456
|
+
body: Any = None,
|
|
457
|
+
method: Method | None = None,
|
|
458
|
+
pool: str | None = None,
|
|
459
|
+
fresh: bool = False,
|
|
460
|
+
wait_up_to: float | None = None,
|
|
461
|
+
) -> PassthroughAnswer:
|
|
462
|
+
"""Sends `api.modrinth.com/v2/<path>` (such as `project/sodium`) through the gateway; Modrinth's answer comes back whatever its status, and only the gateway's own answers raise `ContentGatewayError`."""
|
|
463
|
+
return await self._passthrough(
|
|
464
|
+
self._passthrough_request(
|
|
465
|
+
"modrinth",
|
|
466
|
+
path,
|
|
467
|
+
query=query,
|
|
468
|
+
body=body,
|
|
469
|
+
method=method,
|
|
470
|
+
pool=pool,
|
|
471
|
+
fresh=fresh,
|
|
472
|
+
wait_up_to=wait_up_to,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
async def modrinth_version_file(
|
|
477
|
+
self,
|
|
478
|
+
sha1: str,
|
|
479
|
+
*,
|
|
480
|
+
pool: str | None = None,
|
|
481
|
+
fresh: bool = False,
|
|
482
|
+
wait_up_to: float | None = None,
|
|
483
|
+
) -> PassthroughAnswer:
|
|
484
|
+
"""`GET /v1/modrinth/version_file/<sha1>`: the version Modrinth lists the file under, or a 404."""
|
|
485
|
+
return await self.modrinth(
|
|
486
|
+
f"version_file/{quote(sha1, safe='')}", pool=pool, fresh=fresh, wait_up_to=wait_up_to
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
async def modrinth_version_files(
|
|
490
|
+
self,
|
|
491
|
+
hashes: Sequence[str],
|
|
492
|
+
*,
|
|
493
|
+
pool: str | None = None,
|
|
494
|
+
fresh: bool = False,
|
|
495
|
+
wait_up_to: float | None = None,
|
|
496
|
+
) -> PassthroughAnswer:
|
|
497
|
+
"""`POST /v1/modrinth/version_files`: the versions Modrinth lists these SHA-1s under, keyed by lowercase hash."""
|
|
498
|
+
return await self.modrinth(
|
|
499
|
+
"version_files",
|
|
500
|
+
body={"hashes": list(hashes), "algorithm": "sha1"},
|
|
501
|
+
pool=pool,
|
|
502
|
+
fresh=fresh,
|
|
503
|
+
wait_up_to=wait_up_to,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
async def resolve(
|
|
507
|
+
self,
|
|
508
|
+
file_ids: Sequence[int],
|
|
509
|
+
*,
|
|
510
|
+
cdn: bool | None = None,
|
|
511
|
+
pool: str | None = None,
|
|
512
|
+
fresh: bool = False,
|
|
513
|
+
wait_up_to: float | None = None,
|
|
514
|
+
timeout: float = RESOLVE_TIMEOUT,
|
|
515
|
+
) -> Resolved:
|
|
516
|
+
"""`POST /v1/resolve`: a download address per CurseForge file id."""
|
|
517
|
+
request = self._resolve_request(
|
|
518
|
+
file_ids, cdn=cdn, pool=pool, fresh=fresh, wait_up_to=wait_up_to, timeout=timeout
|
|
519
|
+
)
|
|
520
|
+
sent = await self._send(request)
|
|
521
|
+
return cast(Resolved, sent[1])
|
|
522
|
+
|
|
523
|
+
async def minecraft_versions(
|
|
524
|
+
self, *, type: VersionFilter | None = None, loader: Loader | None = None
|
|
525
|
+
) -> MinecraftVersions:
|
|
526
|
+
"""`GET /v1/minecraft/versions`: the Minecraft versions Mojang lists, newest first."""
|
|
527
|
+
sent = await self._send(self._minecraft_versions_request(type, loader))
|
|
528
|
+
return cast(MinecraftVersions, sent[1])
|
|
529
|
+
|
|
530
|
+
async def minecraft_version(self, id: str) -> MinecraftVersionDetails:
|
|
531
|
+
"""`GET /v1/minecraft/versions/<id>`: one version with the Java major it needs; `UNKNOWN_VERSION` raises."""
|
|
532
|
+
sent = await self._send(self._minecraft_version_request(id))
|
|
533
|
+
return cast(MinecraftVersionDetails, sent[1])
|
|
534
|
+
|
|
535
|
+
async def loader_versions(
|
|
536
|
+
self, loader: Loader, *, minecraft: str | None = None
|
|
537
|
+
) -> LoaderVersions:
|
|
538
|
+
"""`GET /v1/minecraft/loaders/<loader>/versions`: a loader's builds, newest first."""
|
|
539
|
+
sent = await self._send(self._loader_versions_request(loader, minecraft))
|
|
540
|
+
return cast(LoaderVersions, sent[1])
|
|
541
|
+
|
|
542
|
+
async def _passthrough(self, request: _Request) -> PassthroughAnswer:
|
|
543
|
+
response, body = await self._send(request)
|
|
544
|
+
return self._passthrough_answer(request, response, body)
|
|
545
|
+
|
|
546
|
+
async def _send(self, request: _Request) -> tuple[httpx.Response, Any]:
|
|
547
|
+
url, headers, content = self._build(request)
|
|
548
|
+
deadline = time.monotonic() + (request.wait_up_to or 0)
|
|
549
|
+
timeout = request.timeout if request.timeout is not None else self._timeout
|
|
550
|
+
while True:
|
|
551
|
+
response = await self._client.request(
|
|
552
|
+
request.method, url, headers=headers, content=content, timeout=timeout
|
|
553
|
+
)
|
|
554
|
+
outcome = self._settle(request, response, deadline)
|
|
555
|
+
if outcome[0] == "done":
|
|
556
|
+
return response, outcome[1]
|
|
557
|
+
await asyncio.sleep(outcome[1])
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def modrinth_file_url(version: Mapping[str, Any], sha1: str) -> str | None:
|
|
561
|
+
"""The `https://cdn.modrinth.com` address of the file with this SHA-1 in a Modrinth version, or `None`; other files of the version and other hosts never count."""
|
|
562
|
+
wanted = sha1.lower()
|
|
563
|
+
for file in version.get("files") or ():
|
|
564
|
+
if not isinstance(file, Mapping):
|
|
565
|
+
continue
|
|
566
|
+
hashes = file.get("hashes")
|
|
567
|
+
found = hashes.get("sha1") if isinstance(hashes, Mapping) else None
|
|
568
|
+
if not isinstance(found, str) or found.lower() != wanted:
|
|
569
|
+
continue
|
|
570
|
+
url = file.get("url")
|
|
571
|
+
if not isinstance(url, str):
|
|
572
|
+
continue
|
|
573
|
+
try:
|
|
574
|
+
parts = urlsplit(url)
|
|
575
|
+
except ValueError:
|
|
576
|
+
continue
|
|
577
|
+
if parts.scheme == "https" and parts.hostname == MODRINTH_CDN_HOST:
|
|
578
|
+
return url
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _query_string(query: Query | None) -> str:
|
|
583
|
+
if query is None:
|
|
584
|
+
return ""
|
|
585
|
+
pairs = [(name, _query_value(value)) for name, value in query.items() if value is not None]
|
|
586
|
+
return f"?{urlencode(pairs)}" if pairs else ""
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _query_value(value: str | int | float | bool) -> str:
|
|
590
|
+
if isinstance(value, bool):
|
|
591
|
+
return "true" if value else "false"
|
|
592
|
+
return str(value)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _read_body(response: httpx.Response) -> Any:
|
|
596
|
+
text = response.text
|
|
597
|
+
if text == "":
|
|
598
|
+
return None
|
|
599
|
+
if not _JSON_TYPE.search(response.headers.get("content-type", "")):
|
|
600
|
+
return text
|
|
601
|
+
try:
|
|
602
|
+
return json.loads(text)
|
|
603
|
+
except ValueError:
|
|
604
|
+
return text
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _as_api_error(body: Any) -> ApiError | None:
|
|
608
|
+
if not isinstance(body, dict):
|
|
609
|
+
return None
|
|
610
|
+
if not isinstance(body.get("code"), str) or not isinstance(body.get("message"), str):
|
|
611
|
+
return None
|
|
612
|
+
return cast(ApiError, body)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _retry_after(response: httpx.Response, error: ApiError | None) -> float | None:
|
|
616
|
+
if error is not None:
|
|
617
|
+
ms = error.get("retryAfterMs")
|
|
618
|
+
if isinstance(ms, int):
|
|
619
|
+
return ms / 1000
|
|
620
|
+
header = response.headers.get("retry-after", "").strip()
|
|
621
|
+
if _SECONDS.match(header):
|
|
622
|
+
return float(header)
|
|
623
|
+
return None
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# generated by datamodel-codegen:
|
|
2
|
+
# filename: openapi.json
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from typing import Literal, NotRequired, TypeAlias, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DayCounts(TypedDict):
|
|
9
|
+
calls: int
|
|
10
|
+
forbidden: int
|
|
11
|
+
hits: int
|
|
12
|
+
throttled: int
|
|
13
|
+
withheld: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DayPoolSnapshot(DayCounts):
|
|
17
|
+
consumer: str
|
|
18
|
+
pool: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DaySnapshot(TypedDict):
|
|
22
|
+
date: str
|
|
23
|
+
pools: list[DayPoolSnapshot]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ErrorCode: TypeAlias = Literal[
|
|
27
|
+
"UNAUTHORIZED",
|
|
28
|
+
"POOL_REQUIRED",
|
|
29
|
+
"UNKNOWN_POOL",
|
|
30
|
+
"INVALID_PATH",
|
|
31
|
+
"INVALID_DAYS",
|
|
32
|
+
"INVALID_HASH",
|
|
33
|
+
"INVALID_BODY",
|
|
34
|
+
"INVALID_QUERY",
|
|
35
|
+
"UNKNOWN_VERSION",
|
|
36
|
+
"UNKNOWN_LOADER",
|
|
37
|
+
"UPSTREAM_TIMEOUT",
|
|
38
|
+
"UPSTREAM_UNREACHABLE",
|
|
39
|
+
"UPSTREAM_TOO_LARGE",
|
|
40
|
+
"UPSTREAM_UNREADABLE",
|
|
41
|
+
"BUDGET_SPENT",
|
|
42
|
+
"UPSTREAM_HOLD",
|
|
43
|
+
"REQUEST_TOO_LARGE",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
FileSource: TypeAlias = Literal["curseforge", "modrinth", "curseforge-cdn"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Health(TypedDict):
|
|
51
|
+
builtAt: NotRequired[str | None]
|
|
52
|
+
commit: NotRequired[str | None]
|
|
53
|
+
status: str
|
|
54
|
+
version: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Latest(TypedDict):
|
|
58
|
+
release: str
|
|
59
|
+
snapshot: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Loader: TypeAlias = Literal["neoforge", "forge", "fabric"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LoaderVersion(TypedDict):
|
|
66
|
+
minecraft: NotRequired[str | None]
|
|
67
|
+
recommended: bool
|
|
68
|
+
stable: bool
|
|
69
|
+
version: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LoaderVersions(TypedDict):
|
|
73
|
+
loader: Loader
|
|
74
|
+
minecraft: NotRequired[str | None]
|
|
75
|
+
versions: list[LoaderVersion]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PoolSnapshot(TypedDict):
|
|
79
|
+
consumer: str
|
|
80
|
+
pool: str
|
|
81
|
+
used: int
|
|
82
|
+
withheld: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ResolveRequest(TypedDict):
|
|
86
|
+
cdn: NotRequired[bool]
|
|
87
|
+
fileIds: list[int]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ResolvedFile(TypedDict):
|
|
91
|
+
fileId: int
|
|
92
|
+
fileName: NotRequired[str | None]
|
|
93
|
+
retry: bool
|
|
94
|
+
sha1: NotRequired[str | None]
|
|
95
|
+
size: NotRequired[int | None]
|
|
96
|
+
source: NotRequired[FileSource | None]
|
|
97
|
+
url: NotRequired[str | None]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
Source: TypeAlias = Literal["redis", "memory", "unavailable"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UpstreamBudget(TypedDict):
|
|
104
|
+
days: list[DaySnapshot]
|
|
105
|
+
heldUntil: NotRequired[str | None]
|
|
106
|
+
limitPerMinute: int
|
|
107
|
+
pools: list[PoolSnapshot]
|
|
108
|
+
resetsAt: str
|
|
109
|
+
used: int
|
|
110
|
+
withheld: int
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class VersionFilesRequest(TypedDict):
|
|
114
|
+
algorithm: NotRequired[str]
|
|
115
|
+
hashes: list[str]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
VersionFilter: TypeAlias = Literal["release", "snapshot", "all"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
VersionType: TypeAlias = Literal["release", "snapshot", "old_beta", "old_alpha"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ApiError(TypedDict):
|
|
125
|
+
code: ErrorCode
|
|
126
|
+
message: str
|
|
127
|
+
pool: NotRequired[str | None]
|
|
128
|
+
retryAfterMs: NotRequired[int | None]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BudgetSnapshot(TypedDict):
|
|
132
|
+
curseforge: UpstreamBudget
|
|
133
|
+
historyDays: int
|
|
134
|
+
modrinth: UpstreamBudget
|
|
135
|
+
source: Source
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MinecraftVersion(TypedDict):
|
|
139
|
+
id: str
|
|
140
|
+
releaseTime: str
|
|
141
|
+
type: VersionType
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MinecraftVersionDetails(TypedDict):
|
|
145
|
+
id: str
|
|
146
|
+
javaMajor: NotRequired[int | None]
|
|
147
|
+
releaseTime: str
|
|
148
|
+
type: VersionType
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class MinecraftVersions(TypedDict):
|
|
152
|
+
latest: Latest
|
|
153
|
+
versions: list[MinecraftVersion]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Resolved(TypedDict):
|
|
157
|
+
files: list[ResolvedFile]
|
|
File without changes
|