biblindex-client 0.2.2__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,257 @@
1
+ Metadata-Version: 2.3
2
+ Name: biblindex-client
3
+ Version: 0.2.2
4
+ Summary: Python client for the BiblIndex API.
5
+ Keywords: biblindex,api,client,oauth2
6
+ Author: Pierre Hennequart
7
+ Author-email: Pierre Hennequart <pierre@janalis.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Dist: requests>=2.32
20
+ Requires-Python: >=3.9
21
+ Project-URL: Homepage, https://www.biblindex.org/api
22
+ Project-URL: Issues, https://github.com/janalis/biblindex-client/issues
23
+ Project-URL: Source, https://github.com/janalis/biblindex-client
24
+ Description-Content-Type: text/markdown
25
+
26
+ # BiblIndex Python client
27
+
28
+ [![Python](https://img.shields.io/badge/Python-3.12+-blue?logo=python)](https://www.python.org/)
29
+ [![uv](https://img.shields.io/badge/uv-package%20manager-111111?logo=python)](https://docs.astral.sh/uv/)
30
+ [![Make](https://img.shields.io/badge/Make-automation-orange?logo=gnu)](https://www.gnu.org/software/make/)
31
+ ![Cross Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20WSL-lightgrey)
32
+ [![CI](https://github.com/janalis/biblindex-python-client/actions/workflows/ci.yml/badge.svg)](https://github.com/janalis/biblindex-client/actions/workflows/ci.yml)
33
+
34
+ ## Maintainers
35
+
36
+ | Name | Email |
37
+ | ----------------- | ------------------ |
38
+ | Pierre Hennequart | pierre@janalis.com |
39
+
40
+ ## Documentation
41
+
42
+ https://www.biblindex.org/api
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ make setup
48
+ make run
49
+ ```
50
+
51
+ ## Installation
52
+
53
+ ### Clone the repository
54
+
55
+ ```bash
56
+ git clone <repo-url>
57
+ cd <repo-name>
58
+ ```
59
+
60
+ ### Install Python environment
61
+
62
+ This project uses a version-managed Python setup.
63
+
64
+ ```bash
65
+ pyenv install $(cat .python-version)
66
+ pyenv local $(cat .python-version)
67
+ ```
68
+
69
+ ### Install dependencies
70
+
71
+ This project uses a modern Python packaging tool:
72
+
73
+ ```bash
74
+ uv sync
75
+ ```
76
+
77
+ ### Environment variables
78
+
79
+ ```bash
80
+ cp .env .env.local
81
+ ```
82
+
83
+ Edit .env.local with your configuration.
84
+
85
+ ## Run the project
86
+
87
+ ### Using Make (recommended)
88
+
89
+ ```bash
90
+ make help # List all available commands
91
+ make run
92
+ ```
93
+
94
+ ### Or manually
95
+
96
+ ```bash
97
+ uv run python src/example.py
98
+ ```
99
+
100
+ ## Available commands
101
+
102
+ Run `make help` to see all available commands with descriptions:
103
+
104
+ ```bash
105
+ make help
106
+ ```
107
+
108
+ ## Platform Support
109
+
110
+ | OS | Support | Notes |
111
+ |---------|---------|---------------------|
112
+ | macOS | ✅ | Native supported |
113
+ | Linux | ✅ | Native supported |
114
+ | Windows | ⚠️ | Use WSL or Git Bash |
115
+
116
+ ## Notes
117
+
118
+ * Uses pyenv for Python version management
119
+ * Uses uv for fast dependency resolution
120
+ * Makefile orchestrates setup + run steps
121
+
122
+ ## Recommended Setup
123
+
124
+ For the smoothest experience:
125
+
126
+ * macOS / Linux → native terminal
127
+ * Windows → WSL2 (recommended)
128
+
129
+ ## Use as a library in another project
130
+
131
+ Released versions are published to [PyPI](https://pypi.org/project/biblindex-client/).
132
+
133
+ ### With `uv`
134
+
135
+ ```bash
136
+ uv add biblindex-client
137
+ ```
138
+
139
+ ### With `pip`
140
+
141
+ ```bash
142
+ pip install biblindex-client
143
+ ```
144
+
145
+ ### Installing an unreleased commit
146
+
147
+ To use a version that hasn't been released to PyPI yet, install directly from the
148
+ Git repository (optionally pinned to a tag, branch or commit):
149
+
150
+ ```bash
151
+ uv add "biblindex-client @ git+https://github.com/janalis/biblindex-client.git@v0.1.0"
152
+ ```
153
+
154
+ ### Or in `pyproject.toml`
155
+
156
+ ```toml
157
+ [project]
158
+ dependencies = [
159
+ "biblindex-client @ git+https://github.com/janalis/biblindex-client.git@v0.1.0",
160
+ ]
161
+ ```
162
+
163
+ ### Usage
164
+
165
+ ```python
166
+ from biblindex_client import BiblIndexClient
167
+
168
+ client = BiblIndexClient(
169
+ baseUrl="https://www.biblindex.org",
170
+ username="...",
171
+ password="...",
172
+ clientId="...",
173
+ clientSecret="...",
174
+ )
175
+
176
+ quotations = client.request("/api/quotations", {"page": 1})
177
+ ```
178
+
179
+ ### Lazy fetching
180
+
181
+ API responses are automatically wrapped in lazy proxies that defer network requests until data is actually read:
182
+
183
+ - **`LazyResource`** (`MutableMapping`): resource links (e.g. `/api/extracts/42`, `{"@id": "/api/works/1"}`) embedded in responses are wrapped as lazy mappings — the linked resource is fetched only when a field is accessed.
184
+ - **`LazyCollection`** (`MutableSequence`): paginated Hydra collections and plain JSON arrays are wrapped as lazy sequences — subsequent pages are fetched on demand when iterating or indexing beyond the current page.
185
+
186
+ Hydra metadata properties (`hydra:member`, `hydra:view`, `hydra:search`, `hydra:totalItems`) are not exposed through the lazy wrappers — collections are returned directly as `LazyCollection` instances.
187
+
188
+ Caching ensures the same API resource is never fetched twice within a single response tree.
189
+
190
+ ```python
191
+ from biblindex_client import BiblIndexClient, LazyResource
192
+
193
+ client = BiblIndexClient(...)
194
+ collection = client.request("/api/quotations", {"page": 1})
195
+
196
+ # members is a LazyCollection — pages fetched lazily
197
+ item = collection[0] # no network call yet
198
+ print(item["@id"]) # triggers fetch of /api/quotations/1229419
199
+ ```
200
+
201
+ #### Using `application/json` (plain JSON)
202
+
203
+ > **Warning:** Prefer `application/ld+json` (the default) whenever the API supports it. The Hydra JSON-LD format provides metadata (`hydra:totalItems`, `hydra:view`) that enables accurate `len()` and proper next-page resolution via `hydra:next` links. With plain `application/json`, total item count is unavailable and pagination falls back to incrementing `?page=N`, which may yield empty pages at the end.
204
+
205
+ When the API returns plain JSON arrays instead of Hydra collections, configure the `accept` media type:
206
+
207
+ ```python
208
+ from biblindex_client import BiblIndexClient, LazyCollection, LazyResource
209
+
210
+ client = BiblIndexClient(
211
+ baseUrl="https://www.biblindex.org",
212
+ username="...",
213
+ password="...",
214
+ clientId="...",
215
+ clientSecret="...",
216
+ accept="application/json",
217
+ )
218
+
219
+ collection = client.request("/api/quotations", {"page": 1})
220
+
221
+ # The array is wrapped in a LazyCollection — pages are fetched lazily
222
+ print(type(collection)) # <class 'LazyCollection'>
223
+ print(collection.loadedItems) # items loaded so far (page 1)
224
+
225
+ # Accessing beyond the current page triggers ?page=N
226
+ item = collection[2] # fetches /api/quotations?page=2
227
+ print(item["id"]) # reads from the fetched item
228
+ ```
229
+
230
+ Pagination uses the `?page=N` query parameter automatically — each fetch increments the page number. If the API returns an empty array the collection stops fetching further pages.
231
+
232
+ ## Publishing a new version
233
+
234
+ ```bash
235
+ make bump-patch # or bump-minor / bump-major
236
+ ```
237
+
238
+ This bumps the version in `pyproject.toml`, commits, tags (`vX.Y.Z`), and pushes to GitHub. The [Release workflow](.github/workflows/release.yml) then builds the distribution, publishes it to **TestPyPI** and then **PyPI**, and creates a GitHub Release with auto-generated release notes.
239
+
240
+ Publishing uses [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) via `uv publish` — no API tokens or credentials are stored or needed locally. The TestPyPI upload acts as a smoke test that gates the real PyPI release.
241
+
242
+ ## Contributing
243
+
244
+ This project is open to contributions.
245
+
246
+ We welcome pull requests following the standard GitHub flow:
247
+
248
+ 1. Fork the repository
249
+ 2. Create a feature branch
250
+ 3. Commit your changes
251
+ 4. Open a pull request
252
+
253
+ Please ensure your changes are well tested and follow the existing code style.
254
+
255
+ ## API Modifications
256
+
257
+ If you need changes, extensions, or adjustments to the API, please contact the maintainers.
@@ -0,0 +1,232 @@
1
+ # BiblIndex Python client
2
+
3
+ [![Python](https://img.shields.io/badge/Python-3.12+-blue?logo=python)](https://www.python.org/)
4
+ [![uv](https://img.shields.io/badge/uv-package%20manager-111111?logo=python)](https://docs.astral.sh/uv/)
5
+ [![Make](https://img.shields.io/badge/Make-automation-orange?logo=gnu)](https://www.gnu.org/software/make/)
6
+ ![Cross Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20WSL-lightgrey)
7
+ [![CI](https://github.com/janalis/biblindex-python-client/actions/workflows/ci.yml/badge.svg)](https://github.com/janalis/biblindex-client/actions/workflows/ci.yml)
8
+
9
+ ## Maintainers
10
+
11
+ | Name | Email |
12
+ | ----------------- | ------------------ |
13
+ | Pierre Hennequart | pierre@janalis.com |
14
+
15
+ ## Documentation
16
+
17
+ https://www.biblindex.org/api
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ make setup
23
+ make run
24
+ ```
25
+
26
+ ## Installation
27
+
28
+ ### Clone the repository
29
+
30
+ ```bash
31
+ git clone <repo-url>
32
+ cd <repo-name>
33
+ ```
34
+
35
+ ### Install Python environment
36
+
37
+ This project uses a version-managed Python setup.
38
+
39
+ ```bash
40
+ pyenv install $(cat .python-version)
41
+ pyenv local $(cat .python-version)
42
+ ```
43
+
44
+ ### Install dependencies
45
+
46
+ This project uses a modern Python packaging tool:
47
+
48
+ ```bash
49
+ uv sync
50
+ ```
51
+
52
+ ### Environment variables
53
+
54
+ ```bash
55
+ cp .env .env.local
56
+ ```
57
+
58
+ Edit .env.local with your configuration.
59
+
60
+ ## Run the project
61
+
62
+ ### Using Make (recommended)
63
+
64
+ ```bash
65
+ make help # List all available commands
66
+ make run
67
+ ```
68
+
69
+ ### Or manually
70
+
71
+ ```bash
72
+ uv run python src/example.py
73
+ ```
74
+
75
+ ## Available commands
76
+
77
+ Run `make help` to see all available commands with descriptions:
78
+
79
+ ```bash
80
+ make help
81
+ ```
82
+
83
+ ## Platform Support
84
+
85
+ | OS | Support | Notes |
86
+ |---------|---------|---------------------|
87
+ | macOS | ✅ | Native supported |
88
+ | Linux | ✅ | Native supported |
89
+ | Windows | ⚠️ | Use WSL or Git Bash |
90
+
91
+ ## Notes
92
+
93
+ * Uses pyenv for Python version management
94
+ * Uses uv for fast dependency resolution
95
+ * Makefile orchestrates setup + run steps
96
+
97
+ ## Recommended Setup
98
+
99
+ For the smoothest experience:
100
+
101
+ * macOS / Linux → native terminal
102
+ * Windows → WSL2 (recommended)
103
+
104
+ ## Use as a library in another project
105
+
106
+ Released versions are published to [PyPI](https://pypi.org/project/biblindex-client/).
107
+
108
+ ### With `uv`
109
+
110
+ ```bash
111
+ uv add biblindex-client
112
+ ```
113
+
114
+ ### With `pip`
115
+
116
+ ```bash
117
+ pip install biblindex-client
118
+ ```
119
+
120
+ ### Installing an unreleased commit
121
+
122
+ To use a version that hasn't been released to PyPI yet, install directly from the
123
+ Git repository (optionally pinned to a tag, branch or commit):
124
+
125
+ ```bash
126
+ uv add "biblindex-client @ git+https://github.com/janalis/biblindex-client.git@v0.1.0"
127
+ ```
128
+
129
+ ### Or in `pyproject.toml`
130
+
131
+ ```toml
132
+ [project]
133
+ dependencies = [
134
+ "biblindex-client @ git+https://github.com/janalis/biblindex-client.git@v0.1.0",
135
+ ]
136
+ ```
137
+
138
+ ### Usage
139
+
140
+ ```python
141
+ from biblindex_client import BiblIndexClient
142
+
143
+ client = BiblIndexClient(
144
+ baseUrl="https://www.biblindex.org",
145
+ username="...",
146
+ password="...",
147
+ clientId="...",
148
+ clientSecret="...",
149
+ )
150
+
151
+ quotations = client.request("/api/quotations", {"page": 1})
152
+ ```
153
+
154
+ ### Lazy fetching
155
+
156
+ API responses are automatically wrapped in lazy proxies that defer network requests until data is actually read:
157
+
158
+ - **`LazyResource`** (`MutableMapping`): resource links (e.g. `/api/extracts/42`, `{"@id": "/api/works/1"}`) embedded in responses are wrapped as lazy mappings — the linked resource is fetched only when a field is accessed.
159
+ - **`LazyCollection`** (`MutableSequence`): paginated Hydra collections and plain JSON arrays are wrapped as lazy sequences — subsequent pages are fetched on demand when iterating or indexing beyond the current page.
160
+
161
+ Hydra metadata properties (`hydra:member`, `hydra:view`, `hydra:search`, `hydra:totalItems`) are not exposed through the lazy wrappers — collections are returned directly as `LazyCollection` instances.
162
+
163
+ Caching ensures the same API resource is never fetched twice within a single response tree.
164
+
165
+ ```python
166
+ from biblindex_client import BiblIndexClient, LazyResource
167
+
168
+ client = BiblIndexClient(...)
169
+ collection = client.request("/api/quotations", {"page": 1})
170
+
171
+ # members is a LazyCollection — pages fetched lazily
172
+ item = collection[0] # no network call yet
173
+ print(item["@id"]) # triggers fetch of /api/quotations/1229419
174
+ ```
175
+
176
+ #### Using `application/json` (plain JSON)
177
+
178
+ > **Warning:** Prefer `application/ld+json` (the default) whenever the API supports it. The Hydra JSON-LD format provides metadata (`hydra:totalItems`, `hydra:view`) that enables accurate `len()` and proper next-page resolution via `hydra:next` links. With plain `application/json`, total item count is unavailable and pagination falls back to incrementing `?page=N`, which may yield empty pages at the end.
179
+
180
+ When the API returns plain JSON arrays instead of Hydra collections, configure the `accept` media type:
181
+
182
+ ```python
183
+ from biblindex_client import BiblIndexClient, LazyCollection, LazyResource
184
+
185
+ client = BiblIndexClient(
186
+ baseUrl="https://www.biblindex.org",
187
+ username="...",
188
+ password="...",
189
+ clientId="...",
190
+ clientSecret="...",
191
+ accept="application/json",
192
+ )
193
+
194
+ collection = client.request("/api/quotations", {"page": 1})
195
+
196
+ # The array is wrapped in a LazyCollection — pages are fetched lazily
197
+ print(type(collection)) # <class 'LazyCollection'>
198
+ print(collection.loadedItems) # items loaded so far (page 1)
199
+
200
+ # Accessing beyond the current page triggers ?page=N
201
+ item = collection[2] # fetches /api/quotations?page=2
202
+ print(item["id"]) # reads from the fetched item
203
+ ```
204
+
205
+ Pagination uses the `?page=N` query parameter automatically — each fetch increments the page number. If the API returns an empty array the collection stops fetching further pages.
206
+
207
+ ## Publishing a new version
208
+
209
+ ```bash
210
+ make bump-patch # or bump-minor / bump-major
211
+ ```
212
+
213
+ This bumps the version in `pyproject.toml`, commits, tags (`vX.Y.Z`), and pushes to GitHub. The [Release workflow](.github/workflows/release.yml) then builds the distribution, publishes it to **TestPyPI** and then **PyPI**, and creates a GitHub Release with auto-generated release notes.
214
+
215
+ Publishing uses [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) via `uv publish` — no API tokens or credentials are stored or needed locally. The TestPyPI upload acts as a smoke test that gates the real PyPI release.
216
+
217
+ ## Contributing
218
+
219
+ This project is open to contributions.
220
+
221
+ We welcome pull requests following the standard GitHub flow:
222
+
223
+ 1. Fork the repository
224
+ 2. Create a feature branch
225
+ 3. Commit your changes
226
+ 4. Open a pull request
227
+
228
+ Please ensure your changes are well tested and follow the existing code style.
229
+
230
+ ## API Modifications
231
+
232
+ If you need changes, extensions, or adjustments to the API, please contact the maintainers.
@@ -0,0 +1,147 @@
1
+ [project]
2
+ name = "biblindex-client"
3
+ version = "0.2.2"
4
+ description = "Python client for the BiblIndex API."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Pierre Hennequart", email = "pierre@janalis.com" },
10
+ ]
11
+ keywords = ["biblindex", "api", "client", "oauth2"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.32",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://www.biblindex.org/api"
30
+ Source = "https://github.com/janalis/biblindex-client"
31
+ Issues = "https://github.com/janalis/biblindex-client/issues"
32
+
33
+ [dependency-groups]
34
+ # Used only by the example.py runner — not shipped to library consumers.
35
+ dev = [
36
+ "dotenv-flow",
37
+ {include-group = "test"},
38
+ {include-group = "lint"},
39
+ {include-group = "typecheck"},
40
+ ]
41
+ test = [
42
+ "pytest>=8",
43
+ "pytest-cov>=5",
44
+ "responses>=0.25",
45
+ ]
46
+ lint = [
47
+ "ruff>=0.6",
48
+ ]
49
+ typecheck = [
50
+ "mypy>=1.10",
51
+ "types-requests>=2.32",
52
+ ]
53
+ release = [
54
+ "bump-my-version>=0.28",
55
+ ]
56
+
57
+ [build-system]
58
+ requires = ["uv_build>=0.8,<0.9"]
59
+ build-backend = "uv_build"
60
+
61
+ [tool.uv.build-backend]
62
+ module-name = "biblindex_client"
63
+ module-root = "src"
64
+
65
+ # TestPyPI upload target used by the release workflow's smoke-test step
66
+ # (`uv publish --index testpypi`). `explicit` keeps it out of dependency
67
+ # resolution — it's only ever used as a publish destination.
68
+ [[tool.uv.index]]
69
+ name = "testpypi"
70
+ url = "https://test.pypi.org/simple/"
71
+ publish-url = "https://test.pypi.org/legacy/"
72
+ explicit = true
73
+
74
+ [tool.pytest.ini_options]
75
+ minversion = "8.0"
76
+ testpaths = ["tests"]
77
+ addopts = [
78
+ "-ra",
79
+ "--strict-markers",
80
+ "--strict-config",
81
+ ]
82
+ filterwarnings = [
83
+ "error",
84
+ ]
85
+
86
+ [tool.coverage.run]
87
+ source = ["biblindex_client"]
88
+ branch = true
89
+
90
+ [tool.coverage.report]
91
+ show_missing = true
92
+ skip_covered = false
93
+ exclude_also = [
94
+ "pragma: no cover",
95
+ "if TYPE_CHECKING:",
96
+ "raise NotImplementedError",
97
+ ]
98
+
99
+ [tool.ruff]
100
+ line-length = 100
101
+ target-version = "py39"
102
+ extend-exclude = [".venv", "dist", "build"]
103
+
104
+ [tool.ruff.lint]
105
+ select = [
106
+ "E", # pycodestyle errors
107
+ "W", # pycodestyle warnings
108
+ "F", # pyflakes
109
+ "I", # isort
110
+ "B", # flake8-bugbear
111
+ "UP", # pyupgrade
112
+ "SIM", # flake8-simplify
113
+ ]
114
+ ignore = [
115
+ "E501", # line length handled by formatter
116
+ ]
117
+
118
+ [tool.ruff.lint.per-file-ignores]
119
+ "tests/*" = ["B011"]
120
+
121
+ [tool.mypy]
122
+ # Lowest version current mypy supports; ruff's target-version = "py39"
123
+ # already enforces real 3.9 compatibility on the source.
124
+ python_version = "3.10"
125
+ strict = true
126
+ files = ["src/biblindex_client"]
127
+ warn_unused_ignores = true
128
+ warn_redundant_casts = true
129
+ warn_unreachable = true
130
+
131
+ [tool.bumpversion]
132
+ current_version = "0.2.2"
133
+ commit = true
134
+ tag = true
135
+ allow_dirty = false
136
+
137
+ [[tool.bumpversion.files]]
138
+ filename = "pyproject.toml"
139
+ search = 'version = "{current_version}"'
140
+ replace = 'version = "{new_version}"'
141
+
142
+ [[tool.bumpversion.files]]
143
+ filename = "uv.lock"
144
+ search = '''name = "biblindex-client"
145
+ version = "{current_version}"'''
146
+ replace = '''name = "biblindex-client"
147
+ version = "{new_version}"'''
@@ -0,0 +1,11 @@
1
+ """BiblIndex API client package.
2
+
3
+ Public entrypoint:
4
+
5
+ from biblindex_client import BiblIndexClient
6
+ """
7
+
8
+ from biblindex_client.biblindex import BiblIndexClient
9
+ from biblindex_client.lazy import LazyCollection, LazyResource
10
+
11
+ __all__ = ["BiblIndexClient", "LazyCollection", "LazyResource"]
@@ -0,0 +1,382 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from datetime import datetime, timedelta
5
+ from typing import Any
6
+ from urllib.parse import parse_qsl, urlencode, urlparse
7
+
8
+ import requests
9
+
10
+ from biblindex_client.lazy import LazyCollection, LazyResource
11
+
12
+ JSON_LD_MIME_TYPE = "application/ld+json"
13
+
14
+
15
+ class BiblIndexClient:
16
+ """HTTP client for interacting with the BiblIndex API.
17
+
18
+ Handles:
19
+ - Authentication via OAuth2 password grant
20
+ - Automatic token refresh
21
+ - Authenticated GET requests to API resources
22
+
23
+ Attributes:
24
+ baseUrl: Base URL of the API.
25
+ username: API username.
26
+ password: API password.
27
+ clientId: OAuth client ID.
28
+ clientSecret: OAuth client secret.
29
+ accept: Media type used in the ``Accept`` header for API GET requests.
30
+ accessToken: Current access token, or None before first auth.
31
+ refreshToken: Refresh token used to renew access tokens.
32
+ expiresIn: Expiration time of the current access token.
33
+ session: Reusable HTTP session.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ baseUrl: str,
39
+ username: str,
40
+ password: str,
41
+ clientId: str,
42
+ clientSecret: str,
43
+ accept: str = JSON_LD_MIME_TYPE,
44
+ ) -> None:
45
+ """Initialize the API client with credentials and configuration."""
46
+ self.baseUrl: str = baseUrl
47
+ self.username: str = username
48
+ self.password: str = password
49
+ self.clientId: str = clientId
50
+ self.clientSecret: str = clientSecret
51
+ self.accept: str = accept
52
+
53
+ self.accessToken: str | None = None
54
+ self.refreshToken: str | None = None
55
+ self.expiresIn: datetime | None = None
56
+
57
+ self.session: requests.Session = requests.Session()
58
+
59
+ def request(self, resource: str, params: Mapping[str, Any]) -> Any:
60
+ """Perform an authenticated GET request to the API.
61
+
62
+ Automatically fetches tokens if missing, and refreshes them if
63
+ expired before issuing the call. API resource links found in the
64
+ response body are wrapped in lazy resources that are fetched on access.
65
+
66
+ Args:
67
+ resource: API resource path. Must start with a leading slash
68
+ (e.g. ``/api/quotations``); a missing leading slash is
69
+ normalized for convenience.
70
+ params: Query parameters for the request.
71
+
72
+ Returns:
73
+ Parsed JSON response from the API.
74
+
75
+ Raises:
76
+ requests.HTTPError: If the HTTP request fails.
77
+ """
78
+ resource = self._normalizeResource(resource)
79
+ currentResource = self._resourceWithParams(resource, params)
80
+ data = self._requestJson(resource, params)
81
+ cache = {resource: data, currentResource: data}
82
+
83
+ wrapped = self._wrapLinkedResources(
84
+ data,
85
+ currentResource=currentResource,
86
+ cache=cache,
87
+ )
88
+ if isinstance(data, list) and isinstance(wrapped, list):
89
+ return LazyCollection(
90
+ self,
91
+ wrapped,
92
+ currentResource=currentResource,
93
+ nextResource=self._nextPlainJsonPageResource(currentResource),
94
+ totalItems=None,
95
+ cache=cache,
96
+ )
97
+
98
+ return wrapped
99
+
100
+ def _requestJson(self, resource: str, params: Mapping[str, Any]) -> Any:
101
+ """Perform an authenticated GET request and return the raw JSON body."""
102
+ if not self.accessToken:
103
+ self.fetchTokens()
104
+
105
+ if self.expiresIn is not None and self.expiresIn < datetime.now():
106
+ self.refreshTokens()
107
+
108
+ response = self.session.request(
109
+ "GET",
110
+ f"{self.baseUrl}{resource}",
111
+ params=dict(params),
112
+ headers={
113
+ "Authorization": f"Bearer {self.accessToken}",
114
+ "Accept": self.accept,
115
+ },
116
+ )
117
+
118
+ response.raise_for_status()
119
+ return response.json()
120
+
121
+ def _wrapLinkedResources(
122
+ self,
123
+ data: Any,
124
+ *,
125
+ currentResource: str,
126
+ cache: dict[str, Any],
127
+ ) -> Any:
128
+ """Wrap API links embedded in a response body with lazy resources."""
129
+ if isinstance(data, list):
130
+ wrappedItems: list[Any] = []
131
+ for item in data:
132
+ resource = (
133
+ self._linkedResource(item.get("@id"))
134
+ or self._resourceFromCollectionItem(item, currentResource)
135
+ if isinstance(item, dict)
136
+ else self._linkedResource(item)
137
+ )
138
+ if resource is not None and resource != currentResource:
139
+ seed = item if isinstance(item, Mapping) else None
140
+ wrappedItems.append(self._lazyResource(resource, cache, seed))
141
+ continue
142
+
143
+ wrappedItems.append(
144
+ self._wrapLinkedResources(
145
+ item,
146
+ currentResource=currentResource,
147
+ cache=cache,
148
+ )
149
+ )
150
+
151
+ return wrappedItems
152
+
153
+ if isinstance(data, dict):
154
+ hydraMember = data.get("hydra:member")
155
+ if isinstance(hydraMember, list):
156
+ wrappedMembers = self._wrapLinkedResources(
157
+ hydraMember,
158
+ currentResource=currentResource,
159
+ cache=cache,
160
+ )
161
+ return LazyCollection(
162
+ self,
163
+ wrappedMembers,
164
+ currentResource=currentResource,
165
+ nextResource=self._nextPageResource(data),
166
+ totalItems=(
167
+ data["hydra:totalItems"]
168
+ if isinstance(data.get("hydra:totalItems"), int)
169
+ else None
170
+ ),
171
+ cache=cache,
172
+ )
173
+
174
+ return self._wrapLinkedResourceProperties(
175
+ data,
176
+ currentResource=currentResource,
177
+ cache=cache,
178
+ )
179
+
180
+ resource = self._linkedResource(data)
181
+ if resource is None:
182
+ return data
183
+
184
+ if resource == currentResource:
185
+ return data
186
+
187
+ return self._lazyResource(resource, cache)
188
+
189
+ def _wrapLinkedResourceProperties(
190
+ self,
191
+ data: dict[str, Any],
192
+ *,
193
+ currentResource: str,
194
+ cache: dict[str, Any],
195
+ ) -> dict[str, Any]:
196
+ """Wrap resource links in a mapping without replacing its metadata."""
197
+ wrapped: dict[str, Any] = {}
198
+ for key, value in data.items():
199
+ if key in {"@id", "@type"}:
200
+ wrapped[key] = value
201
+ continue
202
+
203
+ if key in {"hydra:member", "hydra:view", "hydra:search", "hydra:totalItems"}:
204
+ continue
205
+
206
+ resource = self._linkedResource(value)
207
+ if resource is not None:
208
+ if resource == currentResource:
209
+ wrapped[key] = value
210
+ continue
211
+
212
+ wrapped[key] = self._lazyResource(resource, cache)
213
+ continue
214
+
215
+ if isinstance(value, dict):
216
+ valueResource = self._linkedResource(value.get("@id"))
217
+ if valueResource is not None and valueResource != currentResource:
218
+ wrapped[key] = self._lazyResource(valueResource, cache, value)
219
+ continue
220
+
221
+ wrapped[key] = self._wrapLinkedResources(
222
+ value,
223
+ currentResource=currentResource,
224
+ cache=cache,
225
+ )
226
+
227
+ return wrapped
228
+
229
+ def _lazyResource(
230
+ self,
231
+ resource: str,
232
+ cache: dict[str, Any],
233
+ seed: Mapping[str, Any] | None = None,
234
+ ) -> Any:
235
+ if resource in cache:
236
+ return cache[resource]
237
+
238
+ lazyResource = LazyResource(self, resource, cache, seed)
239
+ cache[resource] = lazyResource
240
+ return lazyResource
241
+
242
+ def _nextPageResource(self, data: Mapping[str, Any]) -> str | None:
243
+ view = data.get("hydra:view")
244
+ if not isinstance(view, Mapping):
245
+ return None
246
+
247
+ return self._linkedResource(view.get("hydra:next"))
248
+
249
+ def _nextPlainJsonPageResource(self, resource: str) -> str | None:
250
+ parsedResource = urlparse(resource)
251
+ query = dict(parse_qsl(parsedResource.query, keep_blank_values=True))
252
+ rawPage = query.get("page")
253
+ if rawPage is None:
254
+ return None
255
+
256
+ try:
257
+ query["page"] = str(int(rawPage) + 1)
258
+ except ValueError:
259
+ return None
260
+
261
+ nextResource = parsedResource.path
262
+ nextQuery = urlencode(query)
263
+ if nextQuery: # pragma: no branch
264
+ nextResource = f"{nextResource}?{nextQuery}"
265
+
266
+ return nextResource
267
+
268
+ def _resourceFromCollectionItem(
269
+ self,
270
+ item: Mapping[str, Any],
271
+ currentResource: str,
272
+ ) -> str | None:
273
+ """Infer an item resource from a collection item carrying only an id."""
274
+ itemId = item.get("id")
275
+ if itemId is None:
276
+ return None
277
+
278
+ collectionResource = currentResource.split("?", maxsplit=1)[0].rstrip("/")
279
+ if not collectionResource.startswith("/api/"):
280
+ return None
281
+
282
+ if collectionResource.rsplit("/", maxsplit=1)[-1] == str(itemId):
283
+ return None
284
+
285
+ return f"{collectionResource}/{itemId}"
286
+
287
+ def _linkedResource(self, value: Any) -> str | None:
288
+ """Return a normalized API resource path when ``value`` is a link."""
289
+ if not isinstance(value, str):
290
+ return None
291
+
292
+ if value.startswith("http://") or value.startswith("https://"):
293
+ parsedBaseUrl = urlparse(self.baseUrl)
294
+ parsedValue = urlparse(value)
295
+ if (
296
+ parsedValue.scheme != parsedBaseUrl.scheme
297
+ or parsedValue.netloc != parsedBaseUrl.netloc
298
+ ):
299
+ return None
300
+
301
+ value = parsedValue.path
302
+ if parsedValue.query:
303
+ value = f"{value}?{parsedValue.query}"
304
+
305
+ if not value.startswith(("/", "api/")):
306
+ return None
307
+
308
+ resource = self._normalizeResource(value)
309
+ if resource == "/api/token" or not resource.startswith("/api/"):
310
+ return None
311
+
312
+ return resource
313
+
314
+ def _normalizeResource(self, resource: str) -> str:
315
+ """Normalize a resource path so it can be appended to ``baseUrl``."""
316
+ if resource.startswith(self.baseUrl):
317
+ resource = resource[len(self.baseUrl) :]
318
+
319
+ if not resource.startswith("/"):
320
+ resource = "/" + resource
321
+
322
+ if not resource.startswith("/api"):
323
+ resource = "/api" + resource
324
+
325
+ return resource
326
+
327
+ def _resourceWithParams(self, resource: str, params: Mapping[str, Any]) -> str:
328
+ if not params:
329
+ return resource
330
+
331
+ query = urlencode(dict(params), doseq=True)
332
+ separator = "&" if "?" in resource else "?"
333
+
334
+ return f"{resource}{separator}{query}"
335
+
336
+ def fetchTokens(self) -> None:
337
+ """Fetch initial OAuth access and refresh tokens using password grant.
338
+
339
+ Updates :attr:`accessToken`, :attr:`refreshToken`, :attr:`expiresIn`.
340
+ """
341
+ response = self.session.post(
342
+ f"{self.baseUrl}/api/token",
343
+ data={
344
+ "grant_type": "password",
345
+ "username": self.username,
346
+ "password": self.password,
347
+ "client_id": self.clientId,
348
+ "client_secret": self.clientSecret,
349
+ },
350
+ )
351
+
352
+ data = response.json()
353
+
354
+ self.accessToken = data["access_token"]
355
+ self.refreshToken = data["refresh_token"]
356
+ self.expiresIn = datetime.now() + timedelta(seconds=data["expires_in"])
357
+
358
+ def refreshTokens(self) -> None:
359
+ """Refresh the OAuth access token using the stored refresh token.
360
+
361
+ Updates :attr:`accessToken`, :attr:`refreshToken`, :attr:`expiresIn`.
362
+
363
+ Note:
364
+ Renamed from ``refreshToken`` to ``refreshTokens`` so it no
365
+ longer collides with the ``refreshToken`` attribute set after
366
+ authentication.
367
+ """
368
+ response = self.session.post(
369
+ f"{self.baseUrl}/api/token",
370
+ data={
371
+ "grant_type": "refresh_token",
372
+ "refresh_token": self.refreshToken,
373
+ "client_id": self.clientId,
374
+ "client_secret": self.clientSecret,
375
+ },
376
+ )
377
+
378
+ data = response.json()
379
+
380
+ self.accessToken = data["access_token"]
381
+ self.refreshToken = data["refresh_token"]
382
+ self.expiresIn = datetime.now() + timedelta(seconds=data["expires_in"])
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Mapping, MutableMapping, MutableSequence
4
+ from typing import Any, Protocol
5
+
6
+
7
+ class ResourceClient(Protocol):
8
+ """Client behavior required by lazy resources and collections."""
9
+
10
+ def _requestJson(self, resource: str, params: Mapping[str, Any]) -> Any: ...
11
+
12
+ def _wrapLinkedResources(
13
+ self,
14
+ data: Any,
15
+ *,
16
+ currentResource: str,
17
+ cache: dict[str, Any],
18
+ ) -> Any: ...
19
+
20
+ def _nextPlainJsonPageResource(self, resource: str) -> str | None: ...
21
+
22
+ def _nextPageResource(self, data: Mapping[str, Any]) -> str | None: ...
23
+
24
+
25
+ _HYDRA_KEYS = frozenset(
26
+ {
27
+ "hydra:member",
28
+ "hydra:view",
29
+ "hydra:search",
30
+ "hydra:totalItems",
31
+ }
32
+ )
33
+
34
+
35
+ class LazyResource(MutableMapping[str, Any]):
36
+ """Mapping proxy that fetches an API resource when its data is read."""
37
+
38
+ def __init__(
39
+ self,
40
+ client: ResourceClient,
41
+ resource: str,
42
+ cache: dict[str, Any],
43
+ seed: Mapping[str, Any] | None = None,
44
+ ) -> None:
45
+ self._client = client
46
+ self._resource = resource
47
+ self._cache = cache
48
+ self._seed = dict(seed or {})
49
+ self._data: dict[str, Any] | None = None
50
+ self._loading = False
51
+
52
+ @property
53
+ def resource(self) -> str:
54
+ """Normalized API path represented by this lazy resource."""
55
+ return self._resource
56
+
57
+ def _load(self) -> dict[str, Any]:
58
+ if self._data is not None:
59
+ return self._data
60
+
61
+ if self._loading:
62
+ return self._seed
63
+
64
+ self._loading = True
65
+ try:
66
+ raw = self._client._requestJson(self._resource, {})
67
+ self._data = self._client._wrapLinkedResources(
68
+ raw,
69
+ currentResource=self._resource,
70
+ cache=self._cache,
71
+ )
72
+ finally:
73
+ self._loading = False
74
+
75
+ return self._data
76
+
77
+ def __getitem__(self, key: str) -> Any:
78
+ if key in _HYDRA_KEYS:
79
+ raise KeyError(key)
80
+
81
+ if self._data is None and key in {"@id", "@type", "id"} and key in self._seed:
82
+ return self._seed[key]
83
+
84
+ return self._load()[key]
85
+
86
+ def __setitem__(self, key: str, value: Any) -> None:
87
+ if key in _HYDRA_KEYS:
88
+ raise KeyError(key)
89
+ self._load()[key] = value
90
+
91
+ def __delitem__(self, key: str) -> None:
92
+ if key in _HYDRA_KEYS:
93
+ raise KeyError(key)
94
+ del self._load()[key]
95
+
96
+ def __iter__(self) -> Iterator[str]:
97
+ for key in self._load():
98
+ if key not in _HYDRA_KEYS:
99
+ yield key
100
+
101
+ def __len__(self) -> int:
102
+ data = self._load()
103
+ return len(data) - len(_HYDRA_KEYS & data.keys())
104
+
105
+ def __repr__(self) -> str:
106
+ if self._data is None:
107
+ return f"LazyResource({self._resource!r})"
108
+
109
+ return repr(self._data)
110
+
111
+
112
+ class LazyCollection(MutableSequence[Any]):
113
+ """List-like collection that fetches following Hydra pages on demand."""
114
+
115
+ def __init__(
116
+ self,
117
+ client: ResourceClient,
118
+ items: list[Any],
119
+ *,
120
+ currentResource: str,
121
+ nextResource: str | None,
122
+ totalItems: int | None,
123
+ cache: dict[str, Any],
124
+ ) -> None:
125
+ self._client = client
126
+ self._items = items
127
+ self._currentResource = currentResource
128
+ self._nextResource = nextResource
129
+ self._totalItems = totalItems
130
+ self._cache = cache
131
+
132
+ @property
133
+ def loadedItems(self) -> int:
134
+ """Number of items already loaded locally."""
135
+ return len(self._items)
136
+
137
+ def _fetchNextPage(self) -> bool:
138
+ if self._nextResource is None:
139
+ return False
140
+
141
+ nextResource = self._nextResource
142
+ page = self._client._requestJson(nextResource, {})
143
+ if isinstance(page, list):
144
+ if not page:
145
+ self._nextResource = None
146
+ return False
147
+
148
+ wrappedItems = self._client._wrapLinkedResources(
149
+ page,
150
+ currentResource=nextResource,
151
+ cache=self._cache,
152
+ )
153
+ self._items.extend(wrappedItems)
154
+ self._currentResource = nextResource
155
+ self._nextResource = self._client._nextPlainJsonPageResource(nextResource)
156
+ return True
157
+
158
+ if not isinstance(page, dict):
159
+ self._nextResource = None
160
+ return False
161
+
162
+ members = page.get("hydra:member", [])
163
+ if isinstance(members, list):
164
+ wrappedMembers = self._client._wrapLinkedResources(
165
+ members,
166
+ currentResource=nextResource,
167
+ cache=self._cache,
168
+ )
169
+ self._items.extend(wrappedMembers)
170
+
171
+ self._currentResource = nextResource
172
+ self._nextResource = self._client._nextPageResource(page)
173
+ return True
174
+
175
+ def _fetchUntilIndex(self, index: int) -> None:
176
+ while index >= len(self._items) and self._fetchNextPage():
177
+ pass
178
+
179
+ def __getitem__(self, index: int | slice) -> Any:
180
+ if isinstance(index, slice):
181
+ stop = index.stop
182
+ if stop is None:
183
+ self._fetchAllPages()
184
+ elif stop > 0:
185
+ self._fetchUntilIndex(stop - 1)
186
+ return self._items[index]
187
+
188
+ if index < 0:
189
+ self._fetchAllPages()
190
+ else:
191
+ self._fetchUntilIndex(index)
192
+
193
+ return self._items[index]
194
+
195
+ def __setitem__(self, index: int | slice, value: Any) -> None:
196
+ self._items[index] = value
197
+
198
+ def __delitem__(self, index: int | slice) -> None:
199
+ del self._items[index]
200
+
201
+ def __len__(self) -> int:
202
+ return self._totalItems if self._totalItems is not None else len(self._items)
203
+
204
+ def insert(self, index: int, value: Any) -> None:
205
+ self._items.insert(index, value)
206
+
207
+ def __iter__(self) -> Iterator[Any]:
208
+ index = 0
209
+ while True:
210
+ if index < len(self._items):
211
+ yield self._items[index]
212
+ index += 1
213
+ continue
214
+
215
+ if not self._fetchNextPage():
216
+ break
217
+
218
+ def __repr__(self) -> str:
219
+ return f"LazyCollection(loadedItems={len(self._items)}, totalItems={self._totalItems!r})"
220
+
221
+ def _fetchAllPages(self) -> None:
222
+ while self._fetchNextPage():
223
+ pass