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.
- biblindex_client-0.2.2/PKG-INFO +257 -0
- biblindex_client-0.2.2/README.md +232 -0
- biblindex_client-0.2.2/pyproject.toml +147 -0
- biblindex_client-0.2.2/src/biblindex_client/__init__.py +11 -0
- biblindex_client-0.2.2/src/biblindex_client/biblindex.py +382 -0
- biblindex_client-0.2.2/src/biblindex_client/lazy.py +223 -0
|
@@ -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
|
+
[](https://www.python.org/)
|
|
29
|
+
[](https://docs.astral.sh/uv/)
|
|
30
|
+
[](https://www.gnu.org/software/make/)
|
|
31
|
+

|
|
32
|
+
[](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
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://docs.astral.sh/uv/)
|
|
5
|
+
[](https://www.gnu.org/software/make/)
|
|
6
|
+

|
|
7
|
+
[](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
|