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.
Files changed (92) hide show
  1. {cmsdials-2.1.0 → cmsdials-2.3.0}/.gitlab-ci.yml +3 -1
  2. {cmsdials-2.1.0 → cmsdials-2.3.0}/PKG-INFO +52 -17
  3. {cmsdials-2.1.0 → cmsdials-2.3.0}/README.md +51 -16
  4. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/_version.py +3 -3
  5. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/bearer.py +27 -3
  6. cmsdials-2.3.0/cmsdials/auth/client.py +198 -0
  7. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/models.py +12 -0
  8. cmsdials-2.3.0/cmsdials/auth/secret_key.py +65 -0
  9. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/workspace/client.py +3 -3
  10. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/PKG-INFO +52 -17
  11. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/SOURCES.txt +1 -0
  12. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/scm_file_list.json +10 -9
  13. cmsdials-2.3.0/cmsdials.egg-info/scm_version.json +8 -0
  14. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/env.py +8 -3
  15. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_auth_client.py +25 -7
  16. cmsdials-2.3.0/tests/integration/utils.py +23 -0
  17. cmsdials-2.3.0/tests/unit/test_auth.py +265 -0
  18. {cmsdials-2.1.0 → cmsdials-2.3.0}/tox.ini +1 -1
  19. cmsdials-2.1.0/cmsdials/auth/client.py +0 -120
  20. cmsdials-2.1.0/cmsdials/auth/secret_key.py +0 -44
  21. cmsdials-2.1.0/cmsdials.egg-info/scm_version.json +0 -8
  22. cmsdials-2.1.0/tests/integration/utils.py +0 -9
  23. {cmsdials-2.1.0 → cmsdials-2.3.0}/.coveragerc +0 -0
  24. {cmsdials-2.1.0 → cmsdials-2.3.0}/.editorconfig +0 -0
  25. {cmsdials-2.1.0 → cmsdials-2.3.0}/.gitignore +0 -0
  26. {cmsdials-2.1.0 → cmsdials-2.3.0}/.mailmap +0 -0
  27. {cmsdials-2.1.0 → cmsdials-2.3.0}/.pre-commit-config.yaml +0 -0
  28. {cmsdials-2.1.0 → cmsdials-2.3.0}/.tool-versions +0 -0
  29. {cmsdials-2.1.0 → cmsdials-2.3.0}/LICENSE +0 -0
  30. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/__init__.py +0 -0
  31. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/__init__.py +0 -0
  32. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/_base.py +0 -0
  33. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/auth/exceptions.py +0 -0
  34. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/__init__.py +0 -0
  35. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/dataset_index/__init__.py +0 -0
  36. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/dataset_index/client.py +0 -0
  37. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/dataset_index/models.py +0 -0
  38. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/file_index/__init__.py +0 -0
  39. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/file_index/client.py +0 -0
  40. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/file_index/models.py +0 -0
  41. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h1d/__init__.py +0 -0
  42. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h1d/client.py +0 -0
  43. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h1d/models.py +0 -0
  44. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h2d/__init__.py +0 -0
  45. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h2d/client.py +0 -0
  46. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/h2d/models.py +0 -0
  47. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/lumisection/__init__.py +0 -0
  48. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/lumisection/client.py +0 -0
  49. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/lumisection/models.py +0 -0
  50. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/mes/__init__.py +0 -0
  51. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/mes/client.py +0 -0
  52. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/mes/models.py +0 -0
  53. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_bad_lumisection/__init__.py +0 -0
  54. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_bad_lumisection/client.py +0 -0
  55. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_bad_lumisection/models.py +0 -0
  56. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_models_index/__init__.py +0 -0
  57. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_models_index/client.py +0 -0
  58. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/ml_models_index/models.py +0 -0
  59. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/oms_proxy/__init__.py +0 -0
  60. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/oms_proxy/client.py +0 -0
  61. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/oms_proxy/models.py +0 -0
  62. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/run/__init__.py +0 -0
  63. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/run/client.py +0 -0
  64. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/run/models.py +0 -0
  65. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/clients/workspace/__init__.py +0 -0
  66. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/cmsdials.py +0 -0
  67. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/filters.py +0 -0
  68. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/__init__.py +0 -0
  69. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/_json.py +0 -0
  70. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/api_client.py +0 -0
  71. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/base_model.py +0 -0
  72. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials/utils/logger.py +0 -0
  73. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/dependency_links.txt +0 -0
  74. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/requires.txt +0 -0
  75. {cmsdials-2.1.0 → cmsdials-2.3.0}/cmsdials.egg-info/top_level.txt +0 -0
  76. {cmsdials-2.1.0 → cmsdials-2.3.0}/mypy.ini +0 -0
  77. {cmsdials-2.1.0 → cmsdials-2.3.0}/pyproject.toml +0 -0
  78. {cmsdials-2.1.0 → cmsdials-2.3.0}/pytest.ini +0 -0
  79. {cmsdials-2.1.0 → cmsdials-2.3.0}/ruff.toml +0 -0
  80. {cmsdials-2.1.0 → cmsdials-2.3.0}/setup.cfg +0 -0
  81. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/__init__.py +0 -0
  82. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_file_index_client.py +0 -0
  83. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_h1d_client.py +0 -0
  84. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_h2d_client.py +0 -0
  85. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_lumisection_client.py +0 -0
  86. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_mes_client.py +0 -0
  87. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_oms_proxy_client.py +0 -0
  88. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_resume.py +0 -0
  89. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/integration/test_run_client.py +0 -0
  90. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/unit/__init__.py +0 -0
  91. {cmsdials-2.1.0 → cmsdials-2.3.0}/tests/unit/test_base_api_client.py +0 -0
  92. {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
- SECRET_KEY: "$SECRET_KEY"
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.1.0
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
- ![Build Status](https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py/badges/develop/pipeline.svg)
25
- ![Coverage](https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py/badges/develop/coverage.svg)
26
- ![Latest Release](https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py/-/badges/release.svg)
24
+ ![Build Status](https://gitlab.cern.ch/cms-ppd/technical-support/libraries/dials-py/badges/develop/pipeline.svg)
25
+ ![Coverage](https://gitlab.cern.ch/cms-ppd/technical-support/libraries/dials-py/badges/develop/coverage.svg)
26
+ ![Latest Release](https://gitlab.cern.ch/cms-ppd/technical-support/libraries/dials-py/-/badges/release.svg)
27
27
  [![PyPI version](https://badge.fury.io/py/cmsdials.png)](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 key` of any application registered 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.
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 local DIALS
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
- All classes that interface the DIALS service inherits the class `BaseAPIClient` which propagate the `base_url`, `route` and `version` attributes with production values. In order to use dials-py with a local version of DIALS it is possible to overwrite those attributes when instantiating the `AuthClient` and the `Dials` client, for example:
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
- DEV_URL = "http://localhost:8000/"
312
- DEV_CACHE_DIR = ".cache-dev"
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(base_url=DEV_URL)
315
- creds = Credentials.from_creds_file(cache_dir=DEV_CACHE_DIR, client=auth) # Make sure to specify the auth client with overwritten values, using another cache_dir is recommended
316
- dials = Dials(creds, base_url=DEV_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
- SECRET_KEY=... uv run pytest tests
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 [this line](/tests/integration/test_auth_client.py#L33).
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
- SECRET_KEY=... BASE_URL=http://localhost:8000 uv run pytest tests
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
- SECRET_KEY=... uv run tox
387
+ CLIENT_SECRET=... uv run tox
353
388
  ```
354
389
 
355
390
  **[asdf](https://asdf-vm.com/) users**
@@ -1,6 +1,6 @@
1
- ![Build Status](https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py/badges/develop/pipeline.svg)
2
- ![Coverage](https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py/badges/develop/coverage.svg)
3
- ![Latest Release](https://gitlab.cern.ch/cms-dqmdc/libraries/dials-py/-/badges/release.svg)
1
+ ![Build Status](https://gitlab.cern.ch/cms-ppd/technical-support/libraries/dials-py/badges/develop/pipeline.svg)
2
+ ![Coverage](https://gitlab.cern.ch/cms-ppd/technical-support/libraries/dials-py/badges/develop/coverage.svg)
3
+ ![Latest Release](https://gitlab.cern.ch/cms-ppd/technical-support/libraries/dials-py/-/badges/release.svg)
4
4
  [![PyPI version](https://badge.fury.io/py/cmsdials.png)](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 key` of any application registered 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.
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 local DIALS
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
- All classes that interface the DIALS service inherits the class `BaseAPIClient` which propagate the `base_url`, `route` and `version` attributes with production values. In order to use dials-py with a local version of DIALS it is possible to overwrite those attributes when instantiating the `AuthClient` and the `Dials` client, for example:
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
- DEV_URL = "http://localhost:8000/"
289
- DEV_CACHE_DIR = ".cache-dev"
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(base_url=DEV_URL)
292
- creds = Credentials.from_creds_file(cache_dir=DEV_CACHE_DIR, client=auth) # Make sure to specify the auth client with overwritten values, using another cache_dir is recommended
293
- dials = Dials(creds, base_url=DEV_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
- SECRET_KEY=... uv run pytest tests
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 [this line](/tests/integration/test_auth_client.py#L33).
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
- SECRET_KEY=... BASE_URL=http://localhost:8000 uv run pytest tests
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
- SECRET_KEY=... uv run tox
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.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g4b3c689eb'
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
- token: Token = self.client.refresh_token(
142
- self.token_type, self.token, self.refresh_token
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 = "auth/"
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("workspaces", retries=retries)
49
+ return self.__get("", retries=retries)
50
50
 
51
51
  def my_workspace(self, retries=DEFAULT_RETRIES):
52
- return self.__get("user-default-workspace", retries=retries)
52
+ return self.__get("my-default/", retries=retries)