cmsdials 2.1.0__tar.gz → 2.3.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.
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.gitlab-ci.yml +3 -1
- {cmsdials-2.1.0 → cmsdials-2.3.0}/PKG-INFO +52 -17
- {cmsdials-2.1.0 → cmsdials-2.3.0}/README.md +51 -16
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/_version.py +3 -3
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/bearer.py +27 -3
- cmsdials-2.3.0/cmsdials/auth/client.py +198 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/models.py +12 -0
- cmsdials-2.3.0/cmsdials/auth/secret_key.py +65 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/workspace/client.py +3 -3
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/PKG-INFO +52 -17
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/SOURCES.txt +1 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/scm_file_list.json +10 -9
- cmsdials-2.3.0/cmsdials.egg-info/scm_version.json +8 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/env.py +8 -3
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_auth_client.py +25 -7
- cmsdials-2.3.0/tests/integration/utils.py +23 -0
- cmsdials-2.3.0/tests/unit/test_auth.py +265 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tox.ini +1 -1
- cmsdials-2.1.0/cmsdials/auth/client.py +0 -120
- cmsdials-2.1.0/cmsdials/auth/secret_key.py +0 -44
- cmsdials-2.1.0/cmsdials.egg-info/scm_version.json +0 -8
- cmsdials-2.1.0/tests/integration/utils.py +0 -9
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.coveragerc +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.editorconfig +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.gitignore +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.mailmap +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.pre-commit-config.yaml +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/.tool-versions +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/LICENSE +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/_base.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/exceptions.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/dataset_index/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/dataset_index/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/dataset_index/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/file_index/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/file_index/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/file_index/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h1d/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h1d/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h1d/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h2d/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h2d/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h2d/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/lumisection/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/lumisection/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/lumisection/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/mes/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/mes/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/mes/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_bad_lumisection/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_bad_lumisection/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_bad_lumisection/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_models_index/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_models_index/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_models_index/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/oms_proxy/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/oms_proxy/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/oms_proxy/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/run/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/run/client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/run/models.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/workspace/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/cmsdials.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/filters.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/_json.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/api_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/base_model.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/logger.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/dependency_links.txt +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/requires.txt +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/top_level.txt +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/mypy.ini +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/pyproject.toml +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/pytest.ini +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/ruff.toml +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/setup.cfg +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_file_index_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_h1d_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_h2d_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_lumisection_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_mes_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_oms_proxy_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_resume.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_run_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/unit/__init__.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/unit/test_base_api_client.py +0 -0
- {cmsdials-2.1.0 → cmsdials-2.3.0}/uv.lock +0 -0
|
@@ -81,7 +81,9 @@ test_matrix:
|
|
|
81
81
|
coverage_format: cobertura
|
|
82
82
|
path: coverage.xml
|
|
83
83
|
variables:
|
|
84
|
-
|
|
84
|
+
CLIENT_SECRET: "$CLIENT_SECRET"
|
|
85
|
+
# Application owning CLIENT_SECRET, enables the api-access token tests
|
|
86
|
+
CLIENT_ID: "cms-dials-dev-api-client-test"
|
|
85
87
|
rules:
|
|
86
88
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
87
89
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cmsdials
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: The Python api client interface to DIALS service
|
|
5
5
|
Author-email: Gabriel Moreira <gabriel.moreira@cern.ch>
|
|
6
6
|
Project-URL: Repository, https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py
|
|
@@ -21,9 +21,9 @@ Provides-Extra: tqdm
|
|
|
21
21
|
Requires-Dist: tqdm>=4.66.1; extra == "tqdm"
|
|
22
22
|
Dynamic: license-file
|
|
23
23
|
|
|
24
|
-

|
|
25
|
+

|
|
26
|
+

|
|
27
27
|
[](https://badge.fury.io/py/cmsdials)
|
|
28
28
|
|
|
29
29
|
# dials-py
|
|
@@ -46,16 +46,29 @@ pandas, tqdm
|
|
|
46
46
|
|
|
47
47
|
## Usage
|
|
48
48
|
|
|
49
|
-
Before interfacing with any route you need to generate valid credentials, it is possible to authenticate trough the `device authorization flow` or using the `client secret
|
|
49
|
+
Before interfacing with any route you need to generate valid credentials, it is possible to authenticate trough the `device authorization flow` or using the `client id and secret` of an application granted API access in DIALS. Note that, the device flow is an interactively authentication procedure that is possible to distinguish users in DIALS backend and the client secret flow is not interactive and is not possible to distinguish users so it should only be used for automation scripts.
|
|
50
|
+
|
|
51
|
+
Credentials are issued by CERN's Keycloak directly, dials-py never sends your password or client secret to DIALS.
|
|
50
52
|
|
|
51
53
|
### Generating credentials with client secret
|
|
52
54
|
|
|
53
55
|
```python
|
|
54
56
|
from cmsdials.auth.secret_key import Credentials
|
|
55
57
|
|
|
56
|
-
creds = Credentials(token=".....")
|
|
58
|
+
creds = Credentials(client_id="my-application", token=".....")
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
The token is renewed automatically before it expires.
|
|
62
|
+
|
|
63
|
+
To get API access:
|
|
64
|
+
|
|
65
|
+
1. Register an application in the [CERN Application Portal](https://application-portal.web.cern.ch) and enable "My application will need to get tokens using its own client ID and secret".
|
|
66
|
+
2. Ask the DIALS team to grant API access to the application (it is added to the DIALS API access group).
|
|
67
|
+
3. Use the application's client id and secret as shown above.
|
|
68
|
+
|
|
69
|
+
> [!WARNING]
|
|
70
|
+
> `Credentials(token=".....")` without `client_id` authenticates with the `X-CLIENT-SECRET` header, which is deprecated and emits a `DeprecationWarning`. DIALS will stop accepting it, so pass the `client_id` of your application.
|
|
71
|
+
|
|
59
72
|
### Generating credentials with device
|
|
60
73
|
|
|
61
74
|
#### Loading from AuthClient
|
|
@@ -298,9 +311,11 @@ You may need to query the `ml_models_index` client to fetch the models ids you a
|
|
|
298
311
|
|
|
299
312
|
Attention: Those endpoints doesn't return a Pydantic model, instead they are returning a plain json response. Consequently, the method `to_pandas` doesn't work on them.
|
|
300
313
|
|
|
301
|
-
## Usage with
|
|
314
|
+
## Usage with other DIALS instances
|
|
315
|
+
|
|
316
|
+
All classes that interface the DIALS service inherit the class `BaseAPIClient`, which propagates the `base_url`, `route` and `version` attributes with production values. In order to use dials-py with another DIALS instance it is possible to overwrite those attributes when instantiating the `Dials` client.
|
|
302
317
|
|
|
303
|
-
|
|
318
|
+
The `AuthClient` issues tokens for the production DIALS applications by default. For another instance, overwrite the Keycloak public application (`client_id`) and the DIALS application the tokens are issued for (`audience`). The audience must be the `DJANGO_KEYCLOAK_CLIENT_ID` configured in that DIALS backend, and that application must allow token exchange from the public application. For example, with a local instance:
|
|
304
319
|
|
|
305
320
|
```python
|
|
306
321
|
from cmsdials.auth.client import AuthClient
|
|
@@ -308,16 +323,36 @@ from cmsdials.auth.bearer import Credentials
|
|
|
308
323
|
from cmsdials import Dials
|
|
309
324
|
from cmsdials.filters import LumisectionHistogram2DFilters
|
|
310
325
|
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
CLIENT_ID = "<your_public_application>"
|
|
327
|
+
AUDIENCE = "<your_confidential_application>"
|
|
328
|
+
CACHE_DIR = ".cache-local"
|
|
329
|
+
DIALS_URL = "http://localhost:8000/"
|
|
313
330
|
|
|
314
|
-
auth = AuthClient(
|
|
315
|
-
creds = Credentials.from_creds_file(cache_dir=
|
|
316
|
-
dials = Dials(creds, base_url=
|
|
331
|
+
auth = AuthClient(client_id=CLIENT_ID, audience=AUDIENCE)
|
|
332
|
+
creds = Credentials.from_creds_file(cache_dir=CACHE_DIR, client=auth) # Make sure to specify the auth client with overwritten values, using another cache_dir is recommended
|
|
333
|
+
dials = Dials(creds, base_url=DIALS_URL)
|
|
317
334
|
|
|
318
335
|
dials.h2d.list_all(LumisectionHistogram2DFilters(me__regex="EEOT digi occupancy EE +", entries__gte=100, run_number__gte=360392, run_number__lte=365000), max_pages=5)
|
|
319
336
|
```
|
|
320
337
|
|
|
338
|
+
When authenticating with a client secret, pass the same `AuthClient` so the token is issued for that instance:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
from cmsdials.auth.secret_key import Credentials as SecretKeyCredentials
|
|
342
|
+
|
|
343
|
+
creds = SecretKeyCredentials(client_id="my-application", token=".....", client=auth)
|
|
344
|
+
dials = Dials(creds, base_url=DIALS_URL)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
To connect to the development instance of DIALS, use these values in the examples above:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
CLIENT_ID = "cms-dials-dev-public-app"
|
|
351
|
+
AUDIENCE = "cms-dials-dev-confidential-app"
|
|
352
|
+
CACHE_DIR = ".cache-dev"
|
|
353
|
+
DIALS_URL = "https://dev-cmsdials-api.web.cern.ch/"
|
|
354
|
+
```
|
|
355
|
+
|
|
321
356
|
## Development
|
|
322
357
|
|
|
323
358
|
Install the dependencies and the package using `uv`:
|
|
@@ -333,15 +368,15 @@ uv pip install -e .
|
|
|
333
368
|
The repository has some tests written to make sure DIALS responses are compatible with pydantic metaclasses, you can use pytest to run all tests but you need to specify a secret key to authenticate non-interactively against DIALS api:
|
|
334
369
|
|
|
335
370
|
```bash
|
|
336
|
-
|
|
371
|
+
CLIENT_SECRET=... uv run pytest tests
|
|
337
372
|
```
|
|
338
373
|
|
|
339
|
-
The secret key is an api client enabled secret key and can be obtained from the applications portal, any api client secret key whitelisted in DIALS can be used. The interactive authentication flow should be tested manually, an example for this can be found in [
|
|
374
|
+
The secret key is an api client enabled secret key and can be obtained from the applications portal, any api client secret key whitelisted in DIALS can be used. Set `CLIENT_ID` to the client id of the same application to test the api-access token flow (`CLIENT_SECRET=... CLIENT_ID=... uv run pytest tests`); otherwise those tests are skipped and the deprecated `X-CLIENT-SECRET` header is used. Tests target the dev Keycloak applications by default, overwrite them with `AUTH_CLIENT_ID` and `AUTH_AUDIENCE`. The interactive authentication flow should be tested manually, an example for this can be found in [test_auth_client.py](/tests/integration/test_auth_client.py).
|
|
340
375
|
|
|
341
376
|
If testing against a local version of DIALS you need to specify the BASE_URL:
|
|
342
377
|
|
|
343
378
|
```bash
|
|
344
|
-
|
|
379
|
+
CLIENT_SECRET=... BASE_URL=http://localhost:8000 uv run pytest tests
|
|
345
380
|
```
|
|
346
381
|
|
|
347
382
|
#### Tox
|
|
@@ -349,7 +384,7 @@ SECRET_KEY=... BASE_URL=http://localhost:8000 uv run pytest tests
|
|
|
349
384
|
Tox is pre-configured in `tox.ini`, so you can run the following to test against multiple python versions locally:
|
|
350
385
|
|
|
351
386
|
```bash
|
|
352
|
-
|
|
387
|
+
CLIENT_SECRET=... uv run tox
|
|
353
388
|
```
|
|
354
389
|
|
|
355
390
|
**[asdf](https://asdf-vm.com/) users**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-

|
|
2
|
+

|
|
3
|
+

|
|
4
4
|
[](https://badge.fury.io/py/cmsdials)
|
|
5
5
|
|
|
6
6
|
# dials-py
|
|
@@ -23,16 +23,29 @@ pandas, tqdm
|
|
|
23
23
|
|
|
24
24
|
## Usage
|
|
25
25
|
|
|
26
|
-
Before interfacing with any route you need to generate valid credentials, it is possible to authenticate trough the `device authorization flow` or using the `client secret
|
|
26
|
+
Before interfacing with any route you need to generate valid credentials, it is possible to authenticate trough the `device authorization flow` or using the `client id and secret` of an application granted API access in DIALS. Note that, the device flow is an interactively authentication procedure that is possible to distinguish users in DIALS backend and the client secret flow is not interactive and is not possible to distinguish users so it should only be used for automation scripts.
|
|
27
|
+
|
|
28
|
+
Credentials are issued by CERN's Keycloak directly, dials-py never sends your password or client secret to DIALS.
|
|
27
29
|
|
|
28
30
|
### Generating credentials with client secret
|
|
29
31
|
|
|
30
32
|
```python
|
|
31
33
|
from cmsdials.auth.secret_key import Credentials
|
|
32
34
|
|
|
33
|
-
creds = Credentials(token=".....")
|
|
35
|
+
creds = Credentials(client_id="my-application", token=".....")
|
|
34
36
|
```
|
|
35
37
|
|
|
38
|
+
The token is renewed automatically before it expires.
|
|
39
|
+
|
|
40
|
+
To get API access:
|
|
41
|
+
|
|
42
|
+
1. Register an application in the [CERN Application Portal](https://application-portal.web.cern.ch) and enable "My application will need to get tokens using its own client ID and secret".
|
|
43
|
+
2. Ask the DIALS team to grant API access to the application (it is added to the DIALS API access group).
|
|
44
|
+
3. Use the application's client id and secret as shown above.
|
|
45
|
+
|
|
46
|
+
> [!WARNING]
|
|
47
|
+
> `Credentials(token=".....")` without `client_id` authenticates with the `X-CLIENT-SECRET` header, which is deprecated and emits a `DeprecationWarning`. DIALS will stop accepting it, so pass the `client_id` of your application.
|
|
48
|
+
|
|
36
49
|
### Generating credentials with device
|
|
37
50
|
|
|
38
51
|
#### Loading from AuthClient
|
|
@@ -275,9 +288,11 @@ You may need to query the `ml_models_index` client to fetch the models ids you a
|
|
|
275
288
|
|
|
276
289
|
Attention: Those endpoints doesn't return a Pydantic model, instead they are returning a plain json response. Consequently, the method `to_pandas` doesn't work on them.
|
|
277
290
|
|
|
278
|
-
## Usage with
|
|
291
|
+
## Usage with other DIALS instances
|
|
292
|
+
|
|
293
|
+
All classes that interface the DIALS service inherit the class `BaseAPIClient`, which propagates the `base_url`, `route` and `version` attributes with production values. In order to use dials-py with another DIALS instance it is possible to overwrite those attributes when instantiating the `Dials` client.
|
|
279
294
|
|
|
280
|
-
|
|
295
|
+
The `AuthClient` issues tokens for the production DIALS applications by default. For another instance, overwrite the Keycloak public application (`client_id`) and the DIALS application the tokens are issued for (`audience`). The audience must be the `DJANGO_KEYCLOAK_CLIENT_ID` configured in that DIALS backend, and that application must allow token exchange from the public application. For example, with a local instance:
|
|
281
296
|
|
|
282
297
|
```python
|
|
283
298
|
from cmsdials.auth.client import AuthClient
|
|
@@ -285,16 +300,36 @@ from cmsdials.auth.bearer import Credentials
|
|
|
285
300
|
from cmsdials import Dials
|
|
286
301
|
from cmsdials.filters import LumisectionHistogram2DFilters
|
|
287
302
|
|
|
288
|
-
|
|
289
|
-
|
|
303
|
+
CLIENT_ID = "<your_public_application>"
|
|
304
|
+
AUDIENCE = "<your_confidential_application>"
|
|
305
|
+
CACHE_DIR = ".cache-local"
|
|
306
|
+
DIALS_URL = "http://localhost:8000/"
|
|
290
307
|
|
|
291
|
-
auth = AuthClient(
|
|
292
|
-
creds = Credentials.from_creds_file(cache_dir=
|
|
293
|
-
dials = Dials(creds, base_url=
|
|
308
|
+
auth = AuthClient(client_id=CLIENT_ID, audience=AUDIENCE)
|
|
309
|
+
creds = Credentials.from_creds_file(cache_dir=CACHE_DIR, client=auth) # Make sure to specify the auth client with overwritten values, using another cache_dir is recommended
|
|
310
|
+
dials = Dials(creds, base_url=DIALS_URL)
|
|
294
311
|
|
|
295
312
|
dials.h2d.list_all(LumisectionHistogram2DFilters(me__regex="EEOT digi occupancy EE +", entries__gte=100, run_number__gte=360392, run_number__lte=365000), max_pages=5)
|
|
296
313
|
```
|
|
297
314
|
|
|
315
|
+
When authenticating with a client secret, pass the same `AuthClient` so the token is issued for that instance:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
from cmsdials.auth.secret_key import Credentials as SecretKeyCredentials
|
|
319
|
+
|
|
320
|
+
creds = SecretKeyCredentials(client_id="my-application", token=".....", client=auth)
|
|
321
|
+
dials = Dials(creds, base_url=DIALS_URL)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
To connect to the development instance of DIALS, use these values in the examples above:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
CLIENT_ID = "cms-dials-dev-public-app"
|
|
328
|
+
AUDIENCE = "cms-dials-dev-confidential-app"
|
|
329
|
+
CACHE_DIR = ".cache-dev"
|
|
330
|
+
DIALS_URL = "https://dev-cmsdials-api.web.cern.ch/"
|
|
331
|
+
```
|
|
332
|
+
|
|
298
333
|
## Development
|
|
299
334
|
|
|
300
335
|
Install the dependencies and the package using `uv`:
|
|
@@ -310,15 +345,15 @@ uv pip install -e .
|
|
|
310
345
|
The repository has some tests written to make sure DIALS responses are compatible with pydantic metaclasses, you can use pytest to run all tests but you need to specify a secret key to authenticate non-interactively against DIALS api:
|
|
311
346
|
|
|
312
347
|
```bash
|
|
313
|
-
|
|
348
|
+
CLIENT_SECRET=... uv run pytest tests
|
|
314
349
|
```
|
|
315
350
|
|
|
316
|
-
The secret key is an api client enabled secret key and can be obtained from the applications portal, any api client secret key whitelisted in DIALS can be used. The interactive authentication flow should be tested manually, an example for this can be found in [
|
|
351
|
+
The secret key is an api client enabled secret key and can be obtained from the applications portal, any api client secret key whitelisted in DIALS can be used. Set `CLIENT_ID` to the client id of the same application to test the api-access token flow (`CLIENT_SECRET=... CLIENT_ID=... uv run pytest tests`); otherwise those tests are skipped and the deprecated `X-CLIENT-SECRET` header is used. Tests target the dev Keycloak applications by default, overwrite them with `AUTH_CLIENT_ID` and `AUTH_AUDIENCE`. The interactive authentication flow should be tested manually, an example for this can be found in [test_auth_client.py](/tests/integration/test_auth_client.py).
|
|
317
352
|
|
|
318
353
|
If testing against a local version of DIALS you need to specify the BASE_URL:
|
|
319
354
|
|
|
320
355
|
```bash
|
|
321
|
-
|
|
356
|
+
CLIENT_SECRET=... BASE_URL=http://localhost:8000 uv run pytest tests
|
|
322
357
|
```
|
|
323
358
|
|
|
324
359
|
#### Tox
|
|
@@ -326,7 +361,7 @@ SECRET_KEY=... BASE_URL=http://localhost:8000 uv run pytest tests
|
|
|
326
361
|
Tox is pre-configured in `tox.ini`, so you can run the following to test against multiple python versions locally:
|
|
327
362
|
|
|
328
363
|
```bash
|
|
329
|
-
|
|
364
|
+
CLIENT_SECRET=... uv run tox
|
|
330
365
|
```
|
|
331
366
|
|
|
332
367
|
**[asdf](https://asdf-vm.com/) users**
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g044d28ceb'
|
|
@@ -4,6 +4,8 @@ import os.path
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
+
from requests.exceptions import HTTPError
|
|
8
|
+
|
|
7
9
|
from ..utils._json import TokenDecoder, TokenEncoder
|
|
8
10
|
from ..utils.logger import logger
|
|
9
11
|
from ._base import BaseCredentials, TokenState
|
|
@@ -137,10 +139,32 @@ class Credentials(BaseCredentials):
|
|
|
137
139
|
with open(fpath, "w") as filebuffer:
|
|
138
140
|
json.dump(self._token.model_dump(), filebuffer, cls=TokenEncoder)
|
|
139
141
|
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _is_invalid_grant(err: HTTPError) -> bool:
|
|
144
|
+
if err.response is None or err.response.status_code != 400:
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
return err.response.json().get("error") == "invalid_grant"
|
|
148
|
+
except ValueError:
|
|
149
|
+
return False
|
|
150
|
+
|
|
140
151
|
def refresh(self) -> None:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
try:
|
|
153
|
+
token: Token = self.client.refresh_token(
|
|
154
|
+
self.token_type, self.token, self.refresh_token
|
|
155
|
+
)
|
|
156
|
+
except HTTPError as err:
|
|
157
|
+
# Keycloak only accepts a refresh token from the client it was
|
|
158
|
+
# issued to. Credentials cached by releases that authenticated
|
|
159
|
+
# through DIALS hold refresh tokens of the DIALS confidential
|
|
160
|
+
# application, not the public one. Expired or revoked refresh
|
|
161
|
+
# tokens are rejected with the same error.
|
|
162
|
+
if not self._is_invalid_grant(err):
|
|
163
|
+
raise
|
|
164
|
+
logger.info(
|
|
165
|
+
"Refresh token rejected, triggering device authentication flow..."
|
|
166
|
+
)
|
|
167
|
+
token = self.client.device_auth_flow()
|
|
144
168
|
self._set_token(token)
|
|
145
169
|
self.cache_credentials()
|
|
146
170
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ..utils.api_client import BaseAPIClient
|
|
8
|
+
from ..utils.logger import logger
|
|
9
|
+
from .exceptions import PendingAuthorizationError
|
|
10
|
+
from .models import ApiToken, Device, Token
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthClient(BaseAPIClient):
|
|
14
|
+
"""
|
|
15
|
+
Client for CERN's Keycloak, issuing tokens accepted by DIALS.
|
|
16
|
+
|
|
17
|
+
Interactive users authenticate with the device authorization grant on the
|
|
18
|
+
public application, and the resulting token is exchanged for one with the
|
|
19
|
+
DIALS audience. Applications authenticate with their own client id and
|
|
20
|
+
secret on CERN's api-access endpoint, requesting the DIALS audience.
|
|
21
|
+
|
|
22
|
+
The defaults target the production DIALS instance. To use another one,
|
|
23
|
+
override `client_id` and `audience`, e.g. for dev:
|
|
24
|
+
|
|
25
|
+
AuthClient(
|
|
26
|
+
client_id="cms-dials-dev-public-app",
|
|
27
|
+
audience="cms-dials-dev-confidential-app",
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
default_timeout = 30 # seconds
|
|
32
|
+
PRODUCTION_KEYCLOAK_URL = "https://auth.cern.ch/auth/"
|
|
33
|
+
PRODUCTION_KEYCLOAK_REALM = "cern"
|
|
34
|
+
PRODUCTION_CLIENT_ID = "cms-dials-prod-public-app"
|
|
35
|
+
PRODUCTION_AUDIENCE = "cms-dials-prod-confidential-app"
|
|
36
|
+
|
|
37
|
+
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
38
|
+
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105
|
|
39
|
+
ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" # noqa: S105
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*args,
|
|
44
|
+
keycloak_url: Optional[str] = None,
|
|
45
|
+
realm: Optional[str] = None,
|
|
46
|
+
client_id: Optional[str] = None,
|
|
47
|
+
audience: Optional[str] = None,
|
|
48
|
+
**kwargs,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(*args, **kwargs)
|
|
51
|
+
keycloak_url = keycloak_url or self.PRODUCTION_KEYCLOAK_URL
|
|
52
|
+
if not keycloak_url.endswith("/"):
|
|
53
|
+
keycloak_url += "/"
|
|
54
|
+
self.realm_url = (
|
|
55
|
+
f"{keycloak_url}realms/{realm or self.PRODUCTION_KEYCLOAK_REALM}/"
|
|
56
|
+
)
|
|
57
|
+
self.client_id = client_id or self.PRODUCTION_CLIENT_ID
|
|
58
|
+
self.audience = audience or self.PRODUCTION_AUDIENCE
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def token_url(self) -> str:
|
|
62
|
+
return self.realm_url + "protocol/openid-connect/token"
|
|
63
|
+
|
|
64
|
+
def _keycloak_post(self, url: str, data: dict) -> requests.Response:
|
|
65
|
+
return requests.post(
|
|
66
|
+
url=url,
|
|
67
|
+
headers={
|
|
68
|
+
"accept": "application/json",
|
|
69
|
+
"User-Agent": self._build_user_agent(),
|
|
70
|
+
},
|
|
71
|
+
data=data,
|
|
72
|
+
timeout=self.default_timeout,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def new_device(self) -> Device:
|
|
76
|
+
response = self._keycloak_post(
|
|
77
|
+
self.realm_url + "protocol/openid-connect/auth/device",
|
|
78
|
+
{"client_id": self.client_id},
|
|
79
|
+
)
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
return Device(**response.json())
|
|
82
|
+
|
|
83
|
+
def _exchange(self, public_token: dict) -> Token:
|
|
84
|
+
"""
|
|
85
|
+
Exchange a token of the public application for one with the DIALS
|
|
86
|
+
audience. The refresh token kept is the public application's one,
|
|
87
|
+
since refreshing means refreshing it and exchanging again.
|
|
88
|
+
"""
|
|
89
|
+
response = self._keycloak_post(
|
|
90
|
+
self.token_url,
|
|
91
|
+
{
|
|
92
|
+
"client_id": self.client_id,
|
|
93
|
+
"grant_type": self.TOKEN_EXCHANGE_GRANT,
|
|
94
|
+
"subject_token": public_token["access_token"],
|
|
95
|
+
"subject_token_type": self.ACCESS_TOKEN_TYPE,
|
|
96
|
+
"audience": self.audience,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
exchanged = response.json()
|
|
101
|
+
curr_datetime = datetime.now()
|
|
102
|
+
return Token(
|
|
103
|
+
access_token=exchanged["access_token"],
|
|
104
|
+
expires_in=exchanged["expires_in"],
|
|
105
|
+
token_type=exchanged["token_type"],
|
|
106
|
+
refresh_token=public_token["refresh_token"],
|
|
107
|
+
refresh_expires_in=public_token["refresh_expires_in"],
|
|
108
|
+
expires_at=curr_datetime
|
|
109
|
+
+ timedelta(seconds=exchanged["expires_in"]),
|
|
110
|
+
refresh_expires_at=curr_datetime
|
|
111
|
+
+ timedelta(seconds=public_token["refresh_expires_in"]),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def device_token(self, device_code: str) -> Token:
|
|
115
|
+
response = self._keycloak_post(
|
|
116
|
+
self.token_url,
|
|
117
|
+
{
|
|
118
|
+
"client_id": self.client_id,
|
|
119
|
+
"grant_type": self.DEVICE_CODE_GRANT,
|
|
120
|
+
"device_code": device_code,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if response.status_code == 400 and response.json().get("error") in (
|
|
125
|
+
"authorization_pending",
|
|
126
|
+
"slow_down",
|
|
127
|
+
):
|
|
128
|
+
raise PendingAuthorizationError
|
|
129
|
+
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
return self._exchange(response.json())
|
|
132
|
+
|
|
133
|
+
def refresh_token(
|
|
134
|
+
self, token_type: str, access_token: str, refresh_token: str
|
|
135
|
+
) -> Token:
|
|
136
|
+
# token_type and access_token are kept for compatibility, refreshing
|
|
137
|
+
# only needs the public application's refresh token.
|
|
138
|
+
response = self._keycloak_post(
|
|
139
|
+
self.token_url,
|
|
140
|
+
{
|
|
141
|
+
"client_id": self.client_id,
|
|
142
|
+
"grant_type": "refresh_token",
|
|
143
|
+
"refresh_token": refresh_token,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
return self._exchange(response.json())
|
|
148
|
+
|
|
149
|
+
def api_access_token(self, client_id: str, client_secret: str) -> ApiToken:
|
|
150
|
+
"""
|
|
151
|
+
Issue a token for an application with its own client id and secret.
|
|
152
|
+
DIALS only accepts it if the application was granted API access.
|
|
153
|
+
"""
|
|
154
|
+
response = self._keycloak_post(
|
|
155
|
+
self.realm_url + "api-access/token",
|
|
156
|
+
{
|
|
157
|
+
"grant_type": "client_credentials",
|
|
158
|
+
"client_id": client_id,
|
|
159
|
+
"client_secret": client_secret,
|
|
160
|
+
"audience": self.audience,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
response.raise_for_status()
|
|
164
|
+
data = response.json()
|
|
165
|
+
return ApiToken(
|
|
166
|
+
access_token=data["access_token"],
|
|
167
|
+
expires_in=data["expires_in"],
|
|
168
|
+
token_type=data["token_type"],
|
|
169
|
+
expires_at=datetime.now() + timedelta(seconds=data["expires_in"]),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def device_auth_flow(self) -> Token:
|
|
173
|
+
interval = 5 # seconds
|
|
174
|
+
device = self.new_device()
|
|
175
|
+
end_date = datetime.now() + timedelta(seconds=device.expires_in)
|
|
176
|
+
|
|
177
|
+
logger.info(f"This device will expire in {device.expires_in} seconds.")
|
|
178
|
+
logger.info(
|
|
179
|
+
f"Go to the following url and authenticate: {device.verification_uri_complete}"
|
|
180
|
+
)
|
|
181
|
+
logger.info(
|
|
182
|
+
f"Checking authorization status every {interval} seconds..."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
while True:
|
|
186
|
+
time.sleep(interval)
|
|
187
|
+
curr_date = datetime.now()
|
|
188
|
+
if curr_date > end_date:
|
|
189
|
+
raise Exception("Action took too much time, device expired!")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
token = self.device_token(device_code=device.device_code)
|
|
193
|
+
logger.info(
|
|
194
|
+
"Device authorized, authentication finished successfully!"
|
|
195
|
+
)
|
|
196
|
+
return token
|
|
197
|
+
except PendingAuthorizationError:
|
|
198
|
+
logger.info("Device not authorized yet.")
|
|
@@ -23,3 +23,15 @@ RefreshToken = DeviceToken
|
|
|
23
23
|
class Token(DeviceToken):
|
|
24
24
|
expires_at: datetime
|
|
25
25
|
refresh_expires_at: datetime
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiToken(BaseModel):
|
|
29
|
+
"""
|
|
30
|
+
Token issued with client credentials by CERN's api-access endpoint.
|
|
31
|
+
It carries no refresh token: a new one is requested once it expires.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
access_token: str
|
|
35
|
+
expires_in: int
|
|
36
|
+
token_type: str
|
|
37
|
+
expires_at: datetime
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ._base import BaseCredentials
|
|
5
|
+
from .client import AuthClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Credentials(BaseCredentials):
|
|
9
|
+
"""
|
|
10
|
+
Client secret key credentials.
|
|
11
|
+
These credentials use an application's client id and secret to provide
|
|
12
|
+
authorization to automation scripts.
|
|
13
|
+
|
|
14
|
+
With `client_id`, an api-access token is issued by CERN's Keycloak and sent
|
|
15
|
+
as a Bearer token, and a new one is issued before it expires. The
|
|
16
|
+
application must have been granted API access in DIALS.
|
|
17
|
+
|
|
18
|
+
Without `client_id`, the secret is sent in the X-CLIENT-SECRET header.
|
|
19
|
+
This is deprecated and will stop working once DIALS removes the header.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
token: Optional[str],
|
|
25
|
+
client_id: Optional[str] = None,
|
|
26
|
+
client: Optional[AuthClient] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
if not token:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"token must be a non-empty Client secret key string"
|
|
31
|
+
)
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.client_id = client_id
|
|
34
|
+
self.client_secret = token
|
|
35
|
+
|
|
36
|
+
if client_id is None:
|
|
37
|
+
warnings.warn(
|
|
38
|
+
"Credentials without client_id authenticate with the "
|
|
39
|
+
"X-CLIENT-SECRET header, which DIALS will stop accepting. "
|
|
40
|
+
"Pass the application's client_id as well.",
|
|
41
|
+
DeprecationWarning,
|
|
42
|
+
stacklevel=2,
|
|
43
|
+
)
|
|
44
|
+
self.token = token
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
self.client = client or AuthClient()
|
|
48
|
+
self.refresh()
|
|
49
|
+
|
|
50
|
+
def refresh(self) -> None:
|
|
51
|
+
if self.client_id is None:
|
|
52
|
+
return
|
|
53
|
+
api_token = self.client.api_access_token(
|
|
54
|
+
self.client_id, self.client_secret
|
|
55
|
+
)
|
|
56
|
+
self.token = api_token.access_token
|
|
57
|
+
self.token_type = api_token.token_type
|
|
58
|
+
self.expires_in = api_token.expires_in
|
|
59
|
+
self.expires_at = api_token.expires_at
|
|
60
|
+
|
|
61
|
+
def apply(self, headers: dict[str, str]) -> None:
|
|
62
|
+
if self.client_id is None:
|
|
63
|
+
headers["X-CLIENT-SECRET"] = self.token
|
|
64
|
+
else:
|
|
65
|
+
headers["Authorization"] = f"{self.token_type} {self.token}"
|
|
@@ -8,7 +8,7 @@ from ...utils.logger import logger
|
|
|
8
8
|
|
|
9
9
|
class WorkspaceClient(BaseAPIClient):
|
|
10
10
|
default_timeout = 30
|
|
11
|
-
lookup_url = "
|
|
11
|
+
lookup_url = "workspaces/"
|
|
12
12
|
|
|
13
13
|
def __init__(
|
|
14
14
|
self,
|
|
@@ -46,7 +46,7 @@ class WorkspaceClient(BaseAPIClient):
|
|
|
46
46
|
return response.json()
|
|
47
47
|
|
|
48
48
|
def list(self, retries=DEFAULT_RETRIES):
|
|
49
|
-
return self.__get("
|
|
49
|
+
return self.__get("", retries=retries)
|
|
50
50
|
|
|
51
51
|
def my_workspace(self, retries=DEFAULT_RETRIES):
|
|
52
|
-
return self.__get("
|
|
52
|
+
return self.__get("my-default/", retries=retries)
|