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.
@@ -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]