elapi 2.4.1__tar.gz → 2.4.3__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 (97) hide show
  1. {elapi-2.4.1 → elapi-2.4.3}/PKG-INFO +41 -5
  2. {elapi-2.4.1 → elapi-2.4.3}/README.md +39 -4
  3. {elapi-2.4.1 → elapi-2.4.3}/pyproject.toml +2 -1
  4. elapi-2.4.3/src/elapi/VERSION +1 -0
  5. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_names.py +1 -1
  6. elapi-2.4.3/src/elapi/api/__init__.py +36 -0
  7. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/api/api.py +153 -66
  8. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/api/endpoint.py +1 -1
  9. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/api/validators.py +92 -91
  10. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/cli/_plugin_handler.py +20 -18
  11. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/cli/elapi.py +83 -23
  12. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/configuration/__init__.py +74 -5
  13. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/configuration/_overload_history.py +3 -0
  14. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/configuration/config.py +86 -21
  15. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/configuration/overridable_vars.py +8 -18
  16. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/configuration/validators.py +69 -76
  17. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/path.py +26 -117
  18. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/bill_teams.py +38 -8
  19. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/cli.py +81 -12
  20. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/commons/__init__.py +2 -0
  21. elapi-2.4.3/src/elapi/plugins/commons/cli.py +6 -0
  22. elapi-2.4.3/src/elapi/plugins/commons/get_whoami.py +126 -0
  23. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/show_config.py +24 -6
  24. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/highlight.py +6 -2
  25. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/utils/__init__.py +6 -2
  26. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/utils/utils.py +18 -2
  27. elapi-2.4.1/src/elapi/VERSION +0 -1
  28. elapi-2.4.1/src/elapi/api/__init__.py +0 -18
  29. {elapi-2.4.1 → elapi-2.4.3}/LICENSE +0 -0
  30. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/.DS_Store +0 -0
  31. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/__init__.py +0 -0
  32. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/__init__.py +0 -0
  33. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_loggers/__init__.py +0 -0
  34. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_loggers/base.py +0 -0
  35. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_loggers/handlers/__init__.py +0 -0
  36. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_loggers/handlers/base.py +0 -0
  37. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_loggers/handlers/callback.py +0 -0
  38. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_loggers/handlers/stderr.py +0 -0
  39. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_core_init/_utils.py +0 -0
  40. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_vendor/__init__.py +0 -0
  41. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_vendor/haggis/LICENSE +0 -0
  42. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_vendor/haggis/__init__.py +0 -0
  43. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_vendor/haggis/logs.py +0 -0
  44. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/_vendor/haggis/version.py +0 -0
  45. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/cli/__init__.py +0 -0
  46. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/cli/__main__.py +0 -0
  47. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/cli/_venv_state_manager.py +0 -0
  48. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/cli/doc.py +0 -0
  49. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/configuration/_config_history.py +0 -0
  50. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/core_validators/__init__.py +0 -0
  51. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/core_validators/base.py +0 -0
  52. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/core_validators/path.py +0 -0
  53. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/loggers/__init__.py +0 -0
  54. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/loggers/base.py +0 -0
  55. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/loggers/handlers/__init__.py +0 -0
  56. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/loggers/handlers/file.py +0 -0
  57. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/loggers/log_file.py +0 -0
  58. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/.DS_Store +0 -0
  59. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/__init__.py +0 -0
  60. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/__init__.py +0 -0
  61. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/_doc.py +0 -0
  62. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/configuration.py +0 -0
  63. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/formats.py +0 -0
  64. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/generate_table.py +0 -0
  65. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/logger.py +0 -0
  66. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/names.py +0 -0
  67. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/registry.py +0 -0
  68. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/specification.py +0 -0
  69. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/utils.py +0 -0
  70. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/bill_teams/validators.py +0 -0
  71. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/commons/cli_helpers.py +0 -0
  72. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/commons/export.py +0 -0
  73. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/commons/get_data_from_input_or_path.py +0 -0
  74. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/commons/get_information.py +0 -0
  75. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/commons/get_location_from_headers.py +0 -0
  76. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/experiments/__init__.py +0 -0
  77. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/experiments/_doc.py +0 -0
  78. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/experiments/cli.py +0 -0
  79. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/experiments/experiments.py +0 -0
  80. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/experiments/formats.py +0 -0
  81. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/__init__.py +0 -0
  82. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/_yagmail_patch.py +0 -0
  83. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/cli.py +0 -0
  84. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/configuration.py +0 -0
  85. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/mail.py +0 -0
  86. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/names.py +0 -0
  87. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/plugins/mail/yagmail_configuration.py +0 -0
  88. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/__init__.py +0 -0
  89. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/_markdown_doc.py +0 -0
  90. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/_missing.py +0 -0
  91. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/base.py +0 -0
  92. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/colors.py +0 -0
  93. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/formats.py +0 -0
  94. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/styles/rich_utils.py +0 -0
  95. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/utils/messages.py +0 -0
  96. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/utils/typer_patches.py +0 -0
  97. {elapi-2.4.1 → elapi-2.4.3}/src/elapi/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: elapi
3
- Version: 2.4.1
3
+ Version: 2.4.3
4
4
  Summary: elAPI is a powerful, extensible API client for eLabFTW.
5
5
  Keywords: elabftw,api
6
6
  Author: Alexander Haller, Mahadi Xion
@@ -29,6 +29,7 @@ Requires-Dist: pyyaml==6.0.2
29
29
  Requires-Dist: mistune==3.1.3
30
30
  Requires-Dist: click>=8.1.8,<8.2
31
31
  Requires-Dist: httpx-limiter>=0.3.0,<0.4.0
32
+ Requires-Dist: httpx-aiohttp>=0.1.8,<0.2
32
33
  Requires-Dist: jinja2>=3.1.6,<3.2 ; extra == 'mail'
33
34
  Requires-Dist: nameparser>=1.1.3 ; extra == 'mail'
34
35
  Requires-Dist: yagmail>=0.15.293,<0.16 ; extra == 'mail'
@@ -133,7 +134,9 @@ $ elapi init
133
134
  <video src='https://github.com/user-attachments/assets/8d5f69ed-b644-4d75-b816-d06d4e937105'> </video>
134
135
  <p align="center">elapi init demo</p>
135
136
 
136
- That's all! Run [`elapi show-config`](#show-config) to view your configuration details.
137
+ That's all! Run [`elapi whoami`](#elapi-whoami) to see a short summary of your eLab user. Run [
138
+ `elapi show-config`](#show-config) to view
139
+ your configuration details.
137
140
 
138
141
  ## Compatibility
139
142
 
@@ -176,7 +179,8 @@ unsafe_api_token_warning: true
176
179
  enable_http2: false
177
180
  verify_ssl: true
178
181
  timeout: 90
179
- async_rate_limit: 100
182
+ async_rate_limit: 20
183
+ async_capacity: 500
180
184
  development_mode: false
181
185
  ```
182
186
 
@@ -192,9 +196,13 @@ development_mode: false
192
196
  - `timeout` can be modified to your needs. E.g., a poor internet connection might benefit from a higher timeout number.
193
197
  The default timeout is `90` seconds.
194
198
  - `async_rate_limit` controls the maximum number of requests per second (i.e., throughput) you want to make to the
195
- server. Default `async_rate_limit` is `null` which is to say no limit. An eLab server might be configured to limit
199
+ server. The default `async_rate_limit` is `null` which means no limit. An eLab server might be configured to limit
196
200
  a high number of requests to prevent spam, `async_rate_limit` can then be set to the maximum number of requests
197
201
  allowed by the server.
202
+ - `async_capacity` controls the maximum number of in-flight async requests. The default `async_capacity` is `null` which
203
+ means no limit on capacity. Both `async_rate_limit` and `async_capacity` can be used to better limit the traffic load
204
+ put on the server. See this [SA answer](https://stackoverflow.com/a/52100884/7696241) about how they differ from each
205
+ other.
198
206
  - `development_mode` can be set to `True` to show debug logs, Python traceback on the CLI instead of a clean exit, etc.
199
207
  This mode should not be turned on for production-ready scripts.
200
208
 
@@ -218,6 +226,33 @@ as detected by `elapi show-config`. All plugins will also automatically listen t
218
226
  be useful to set certain configurations temporarily. E.g., `elapi --OC '{"timeout": 300"}' get info` will override
219
227
  the `timeout` from the configuration files.
220
228
 
229
+ ## elAPI `whoami`
230
+
231
+ Oftentimes, you work with multiple API tokens/keys for multiple teams on multiple eLab servers. elAPI enables robust
232
+ workflow management with built-in [advanced configuration](#advanced-configuration) orchestration. During development
233
+ though,
234
+ juggling between multiple API keys can still lead to confusion as to which API key is being actually used. elAPI version
235
+ `2.4.1` adds the `whomai` command that shows the essential information about the requesting eLab user.
236
+
237
+ ```shell
238
+ $ elapi whoami
239
+ ```
240
+
241
+ ![elapi whoami output](https://heibox.uni-heidelberg.de/f/e3096d5659db45e58a94/?dl=1)
242
+
243
+ So, if your API task requires write permission, but only `Read-only` is shown next to the API key, you know you're using
244
+ the wrong API key. But having to run `elapi whoami` every single time for this would be cumbersome. So, we added the
245
+ `whoami` information to the debug messages. To enable debug messages, set `development_mode` to `True` in
246
+ the [configuration file](#configuration). Once set, before any elAPI command and plugin is run, elAPI will show the
247
+ `whoami` information in the debug messages.
248
+
249
+ ```shell
250
+ DEBUG Based on the detected configuration, the requests will be made to the server get_whoami.py:114
251
+ https://elabftw-dev-002.uni-heidelberg.de (eLabFTW version: 5.2.8), with API token '84-fc******2a384' (Read/Write),
252
+ by user 'John Doe' (ID: 2607), from team 'North Kathy' (ID: 19), in user group 'User', not as a sysadmin. Scopes:
253
+ experiments = 'Team', items = 'Team', experiments_templates = 'Team', teamgroups = 'Everything'
254
+ ```
255
+
221
256
  ## Examples and usage
222
257
 
223
258
  Check out the [examples directory](https://github.com/uhd-urz/elAPI/tree/main/examples) for some comprehensive code
@@ -318,7 +353,8 @@ $ elapi experiments upload-attachment --id <experiment ID> --path <path to attac
318
353
 
319
354
  Sometimes when a script has finished, you and your team would want to receive an email about its success/failure. elAPI
320
355
  offers a no-code solution for this: the `mail` plugin. The `mail` plugin is not enabled by default. To enable it,
321
- install elAPI with the optional dependency `mail`: `pip install elapi[mail]` or `uv add elapi[mail]`. [__See the wiki__](https://github.com/uhd-urz/elAPI/wiki/mail-plugin) to read more about how to configure the `mail` plugin with your
356
+ install elAPI with the optional dependency `mail`: `pip install elapi[mail]` or `uv add elapi[mail]`. [__See the wiki
357
+ __](https://github.com/uhd-urz/elAPI/wiki/mail-plugin) to read more about how to configure the `mail` plugin with your
322
358
  mail server configuration and trigger conditions. In a
323
359
  nutshell, the `mail` plugin will scan the logs when a script/plugin is done to look for pre-configured trigger
324
360
  conditions, and if found, it will send an email. The trigger conditions can be atomic and scope-based, i.e., an email
@@ -85,7 +85,9 @@ $ elapi init
85
85
  <video src='https://github.com/user-attachments/assets/8d5f69ed-b644-4d75-b816-d06d4e937105'> </video>
86
86
  <p align="center">elapi init demo</p>
87
87
 
88
- That's all! Run [`elapi show-config`](#show-config) to view your configuration details.
88
+ That's all! Run [`elapi whoami`](#elapi-whoami) to see a short summary of your eLab user. Run [
89
+ `elapi show-config`](#show-config) to view
90
+ your configuration details.
89
91
 
90
92
  ## Compatibility
91
93
 
@@ -128,7 +130,8 @@ unsafe_api_token_warning: true
128
130
  enable_http2: false
129
131
  verify_ssl: true
130
132
  timeout: 90
131
- async_rate_limit: 100
133
+ async_rate_limit: 20
134
+ async_capacity: 500
132
135
  development_mode: false
133
136
  ```
134
137
 
@@ -144,9 +147,13 @@ development_mode: false
144
147
  - `timeout` can be modified to your needs. E.g., a poor internet connection might benefit from a higher timeout number.
145
148
  The default timeout is `90` seconds.
146
149
  - `async_rate_limit` controls the maximum number of requests per second (i.e., throughput) you want to make to the
147
- server. Default `async_rate_limit` is `null` which is to say no limit. An eLab server might be configured to limit
150
+ server. The default `async_rate_limit` is `null` which means no limit. An eLab server might be configured to limit
148
151
  a high number of requests to prevent spam, `async_rate_limit` can then be set to the maximum number of requests
149
152
  allowed by the server.
153
+ - `async_capacity` controls the maximum number of in-flight async requests. The default `async_capacity` is `null` which
154
+ means no limit on capacity. Both `async_rate_limit` and `async_capacity` can be used to better limit the traffic load
155
+ put on the server. See this [SA answer](https://stackoverflow.com/a/52100884/7696241) about how they differ from each
156
+ other.
150
157
  - `development_mode` can be set to `True` to show debug logs, Python traceback on the CLI instead of a clean exit, etc.
151
158
  This mode should not be turned on for production-ready scripts.
152
159
 
@@ -170,6 +177,33 @@ as detected by `elapi show-config`. All plugins will also automatically listen t
170
177
  be useful to set certain configurations temporarily. E.g., `elapi --OC '{"timeout": 300"}' get info` will override
171
178
  the `timeout` from the configuration files.
172
179
 
180
+ ## elAPI `whoami`
181
+
182
+ Oftentimes, you work with multiple API tokens/keys for multiple teams on multiple eLab servers. elAPI enables robust
183
+ workflow management with built-in [advanced configuration](#advanced-configuration) orchestration. During development
184
+ though,
185
+ juggling between multiple API keys can still lead to confusion as to which API key is being actually used. elAPI version
186
+ `2.4.1` adds the `whomai` command that shows the essential information about the requesting eLab user.
187
+
188
+ ```shell
189
+ $ elapi whoami
190
+ ```
191
+
192
+ ![elapi whoami output](https://heibox.uni-heidelberg.de/f/e3096d5659db45e58a94/?dl=1)
193
+
194
+ So, if your API task requires write permission, but only `Read-only` is shown next to the API key, you know you're using
195
+ the wrong API key. But having to run `elapi whoami` every single time for this would be cumbersome. So, we added the
196
+ `whoami` information to the debug messages. To enable debug messages, set `development_mode` to `True` in
197
+ the [configuration file](#configuration). Once set, before any elAPI command and plugin is run, elAPI will show the
198
+ `whoami` information in the debug messages.
199
+
200
+ ```shell
201
+ DEBUG Based on the detected configuration, the requests will be made to the server get_whoami.py:114
202
+ https://elabftw-dev-002.uni-heidelberg.de (eLabFTW version: 5.2.8), with API token '84-fc******2a384' (Read/Write),
203
+ by user 'John Doe' (ID: 2607), from team 'North Kathy' (ID: 19), in user group 'User', not as a sysadmin. Scopes:
204
+ experiments = 'Team', items = 'Team', experiments_templates = 'Team', teamgroups = 'Everything'
205
+ ```
206
+
173
207
  ## Examples and usage
174
208
 
175
209
  Check out the [examples directory](https://github.com/uhd-urz/elAPI/tree/main/examples) for some comprehensive code
@@ -270,7 +304,8 @@ $ elapi experiments upload-attachment --id <experiment ID> --path <path to attac
270
304
 
271
305
  Sometimes when a script has finished, you and your team would want to receive an email about its success/failure. elAPI
272
306
  offers a no-code solution for this: the `mail` plugin. The `mail` plugin is not enabled by default. To enable it,
273
- install elAPI with the optional dependency `mail`: `pip install elapi[mail]` or `uv add elapi[mail]`. [__See the wiki__](https://github.com/uhd-urz/elAPI/wiki/mail-plugin) to read more about how to configure the `mail` plugin with your
307
+ install elAPI with the optional dependency `mail`: `pip install elapi[mail]` or `uv add elapi[mail]`. [__See the wiki
308
+ __](https://github.com/uhd-urz/elAPI/wiki/mail-plugin) to read more about how to configure the `mail` plugin with your
274
309
  mail server configuration and trigger conditions. In a
275
310
  nutshell, the `mail` plugin will scan the logs when a script/plugin is done to look for pre-configured trigger
276
311
  conditions, and if found, it will send an email. The trigger conditions can be atomic and scope-based, i.e., an email
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "elapi"
3
- version = "2.4.1"
3
+ version = "2.4.3"
4
4
  description = "elAPI is a powerful, extensible API client for eLabFTW."
5
5
  authors = [{ name = "Alexander Haller, Mahadi Xion", email = "elabftw@uni-heidelberg.de" }]
6
6
  requires-python = ">=3.11.0,<4"
@@ -34,6 +34,7 @@ dependencies = [
34
34
  "mistune==3.1.3",
35
35
  "click>=8.1.8,<8.2",
36
36
  "httpx-limiter>=0.3.0,<0.4.0",
37
+ "httpx-aiohttp>=0.1.8,<0.2",
37
38
  ]
38
39
  license-files = [
39
40
  "LICENSE",
@@ -0,0 +1 @@
1
+ 2.4.3
@@ -27,7 +27,6 @@ ELAB_HOST_URL_API_SUFFIX: str = f"/api/v{ELAB_API_EXPECTED_VERSION}"
27
27
  ENV_XDG_DATA_HOME: str = "XDG_DATA_HOME"
28
28
  ENV_XDG_DOWNLOAD_DIR: str = "XDG_DOWNLOAD_DIR"
29
29
  ENV_XDG_CONFIG_HOME: str = "XDG_CONFIG_HOME"
30
- TMP_DIR: Path = Path(f"/var/tmp/{APP_NAME}")
31
30
 
32
31
  # Fallback definitions
33
32
  FALLBACK_DIR: Path = user_home / ".local/share" / APP_NAME
@@ -48,6 +47,7 @@ KEY_API_TOKEN: str = "API_TOKEN"
48
47
  KEY_EXPORT_DIR: str = "EXPORT_DIR"
49
48
  KEY_UNSAFE_TOKEN_WARNING: str = "UNSAFE_API_TOKEN_WARNING"
50
49
  KEY_ENABLE_HTTP2: str = "ENABLE_HTTP2"
50
+ KEY_ASYNC_CAPACITY: str = "ASYNC_CAPACITY"
51
51
  KEY_VERIFY_SSL: str = "VERIFY_SSL"
52
52
  KEY_TIMEOUT: str = "TIMEOUT"
53
53
  KEY_ASYNC_RATE_LIMIT: str = "ASYNC_RATE_LIMIT"
@@ -0,0 +1,36 @@
1
+ __all__ = [
2
+ "SessionDefaults",
3
+ "SimpleClient",
4
+ "GlobalSharedSession",
5
+ "APIRequest",
6
+ "ElabFTWURLError",
7
+ "ElabFTWURL",
8
+ "GETRequest",
9
+ "AsyncGETRequest",
10
+ "POSTRequest",
11
+ "AsyncPOSTRequest",
12
+ "PATCHRequest",
13
+ "AsyncPATCHRequest",
14
+ "DELETERequest",
15
+ "AsyncDELETERequest",
16
+ "FixedEndpoint",
17
+ "FixedAsyncEndpoint",
18
+ ]
19
+
20
+ from .api import (
21
+ APIRequest,
22
+ AsyncDELETERequest,
23
+ AsyncGETRequest,
24
+ AsyncPATCHRequest,
25
+ AsyncPOSTRequest,
26
+ DELETERequest,
27
+ ElabFTWURL,
28
+ ElabFTWURLError,
29
+ GETRequest,
30
+ GlobalSharedSession,
31
+ PATCHRequest,
32
+ POSTRequest,
33
+ SessionDefaults,
34
+ SimpleClient,
35
+ )
36
+ from .endpoint import FixedAsyncEndpoint, FixedEndpoint
@@ -8,29 +8,50 @@ from typing import Literal, Optional, Tuple, Union
8
8
 
9
9
  # noinspection PyProtectedMember
10
10
  import httpx._client as httpx_private_client_module
11
- from httpx import AsyncBaseTransport, AsyncClient, Client, Limits, Response
11
+ from httpx import (
12
+ AsyncBaseTransport,
13
+ AsyncClient,
14
+ BaseTransport,
15
+ Client,
16
+ Limits,
17
+ Response,
18
+ )
12
19
 
13
20
  # noinspection PyProtectedMember
14
21
  from httpx._client import BaseClient
15
22
 
16
23
  # noinspection PyProtectedMember
17
24
  from httpx._types import AuthTypes
25
+ from httpx_aiohttp import AiohttpTransport
18
26
  from httpx_auth import HeaderApiKey
19
27
  from httpx_limiter import AsyncRateLimitedTransport, Rate
20
28
 
21
- from .. import APP_BRAND_NAME, APP_NAME
29
+ from .. import APP_BRAND_NAME
22
30
  from ..configuration import (
31
+ KEY_API_TOKEN,
32
+ KEY_ASYNC_RATE_LIMIT,
33
+ KEY_ENABLE_HTTP2,
34
+ KEY_HOST,
35
+ KEY_TIMEOUT,
36
+ KEY_VERIFY_SSL,
23
37
  TOKEN_BEARER,
38
+ MinimalActiveConfiguration,
24
39
  get_active_api_token,
40
+ get_active_async_capacity,
25
41
  get_active_async_rate_limit,
26
42
  get_active_enable_http2,
27
43
  get_active_host,
28
44
  get_active_timeout,
29
45
  get_active_verify_ssl,
46
+ preventive_missing_warning,
30
47
  )
48
+ from ..configuration.config import APIToken
31
49
  from ..loggers import Logger
32
50
  from ..styles import Missing
33
- from ..utils import get_app_version, update_kwargs_with_defaults
51
+ from ..utils import (
52
+ get_app_version,
53
+ update_kwargs_with_defaults,
54
+ )
34
55
 
35
56
  USER_AGENT: str = (
36
57
  f"{APP_BRAND_NAME}/{get_app_version()} {httpx_private_client_module.USER_AGENT}"
@@ -55,90 +76,126 @@ class SessionDefaults:
55
76
  session_defaults = SessionDefaults()
56
77
 
57
78
 
58
- class _CustomHeaderApiKey(HeaderApiKey): ...
79
+ class _CustomHeaderApiKey(HeaderApiKey):
80
+ def __init__(self):
81
+ self.api_key = str(Missing())
82
+ self.header_name = TOKEN_BEARER
83
+ super().__init__(self.api_key, self.header_name)
59
84
 
60
85
 
61
86
  class SimpleClient(BaseClient):
62
- def __new__(
63
- cls,
64
- *,
65
- is_async_client: bool,
66
- auth: Optional[AuthTypes] = _CustomHeaderApiKey(
67
- api_key=str(Missing()), header_name=TOKEN_BEARER
68
- ),
69
- transport: Optional[AsyncBaseTransport] = None,
70
- **kwargs,
71
- ) -> Union[Client, AsyncClient]:
72
- from .._names import CONFIG_FILE_NAME
73
- from ..configuration import (
74
- KEY_API_TOKEN,
75
- KEY_ASYNC_RATE_LIMIT,
76
- KEY_ENABLE_HTTP2,
77
- KEY_HOST,
78
- KEY_TIMEOUT,
79
- KEY_VERIFY_SSL,
80
- preventive_missing_warning,
81
- )
82
- from ..utils import check_reserved_keyword
83
-
84
- host: str = get_active_host()
85
- api_token: str = get_active_api_token().token
86
- enable_http2: bool = get_active_enable_http2()
87
- verify_ssl: bool = get_active_verify_ssl()
88
- timeout: float = get_active_timeout()
89
- active_async_rate_limit: Optional[int] = get_active_async_rate_limit()
90
-
91
- if isinstance(auth, _CustomHeaderApiKey):
92
- auth.api_key = api_token
93
- for config_field in (
87
+ def __new__(cls, *, is_async_client: bool, **kwargs) -> Union[Client, AsyncClient]:
88
+ host: str = kwargs.pop("host", get_active_host())
89
+ api_token: str | APIToken = kwargs.pop("api_token", get_active_api_token())
90
+ if not isinstance(api_token, APIToken):
91
+ api_token = APIToken(api_token)
92
+ auth: Optional[AuthTypes] = kwargs.pop("auth", _CustomHeaderApiKey())
93
+ if auth is None:
94
+ auth: AuthTypes = _CustomHeaderApiKey()
95
+ auth.api_key = api_token.token
96
+ logger.debug(
97
+ f"Argument 'auth' passed to {SimpleClient.__name__} is {None}. "
98
+ f"It has been be set to {HeaderApiKey} with "
99
+ f"API key/token '{api_token}'."
100
+ )
101
+ elif isinstance(auth, HeaderApiKey):
102
+ auth.api_key = api_token.token
103
+ else:
104
+ logger.debug(
105
+ f"'auth' has been set to {auth!r} for {SimpleClient.__name__} "
106
+ f"by an external method."
107
+ f"API token from configuration file '{api_token}' will be ignored."
108
+ )
109
+ enable_http2: bool = kwargs.pop("http2", get_active_enable_http2())
110
+ verify_ssl: bool = kwargs.pop("verify", get_active_verify_ssl())
111
+ timeout: float = kwargs.pop("timeout", get_active_timeout())
112
+ async_rate_limit: Optional[int] = kwargs.pop(
113
+ "async_rate_limit", get_active_async_rate_limit()
114
+ )
115
+ async_capacity: Optional[int] = kwargs.pop(
116
+ "async_capacity", get_active_async_capacity()
117
+ )
118
+ async_transport: Optional[AsyncBaseTransport] = kwargs.pop(
119
+ "async_transport", None
120
+ )
121
+ sync_transport: Optional[BaseTransport] = kwargs.pop("sync_transport", None)
122
+
123
+ for key_name, value in (
94
124
  (KEY_HOST, host),
95
125
  (KEY_API_TOKEN, api_token),
96
126
  (KEY_ENABLE_HTTP2, enable_http2),
97
127
  (KEY_VERIFY_SSL, verify_ssl),
98
128
  (KEY_TIMEOUT, timeout),
99
129
  ):
100
- preventive_missing_warning(config_field)
101
- try:
102
- if is_async_client is True:
103
- if transport is None and active_async_rate_limit is not None:
104
- transport = AsyncRateLimitedTransport.create(
105
- rate=Rate.create(magnitude=active_async_rate_limit, duration=1),
106
- # see more: https://midnighter.github.io/httpx-limiter/latest/tutorial/#single-rate-limit
107
- # In our case, magnitude=1, duration=1/active_async_rate_limit yields worse results.
108
- http2=enable_http2, # type: ignore
109
- # See: https://github.com/Midnighter/httpx-limiter/issues/7#issuecomment-3197487921
110
- # http2 needs to be passed here if AsyncRateLimitedTransport is used
111
- verify=verify_ssl, # type: ignore
112
- )
113
- elif transport is not None and active_async_rate_limit is not None:
130
+ preventive_missing_warning((key_name, value))
131
+ if MinimalActiveConfiguration().get_value(key_name) != value:
132
+ logger.debug(
133
+ f"Configuration value for '{key_name}' "
134
+ f"is set to '{value}' for {SimpleClient.__name__} by an "
135
+ f"external method."
136
+ )
137
+ if is_async_client is True:
138
+ if async_transport is None and async_rate_limit is not None:
139
+ # noinspection PyTypeChecker
140
+ async_transport = AsyncRateLimitedTransport.create(
141
+ rate=Rate.create(magnitude=async_rate_limit, duration=1),
142
+ # see more: https://midnighter.github.io/httpx-limiter/latest/tutorial/#single-rate-limit
143
+ # In our case, magnitude=1, duration=1/async_rate_limit yields worse results.
144
+ http2=enable_http2, # type: ignore
145
+ # See: https://github.com/Midnighter/httpx-limiter/issues/7#issuecomment-3197487921
146
+ # http2 needs to be passed here if AsyncRateLimitedTransport is used
147
+ verify=verify_ssl, # type: ignore
148
+ )
149
+ logger.debug(
150
+ f"Async rate limit is set to {async_transport.__class__.__name__} "
151
+ f"instance {async_transport!r}: {async_rate_limit}"
152
+ )
153
+ elif async_transport is not None:
154
+ logger.debug(
155
+ f"Async transport has been set to {async_transport!r} by an "
156
+ f"external method."
157
+ )
158
+ if async_rate_limit is not None:
114
159
  logger.warning(
115
160
  f"When a manual transport parameter is passed to {SimpleClient}, "
116
161
  f"the '{KEY_ASYNC_RATE_LIMIT}' in the configuration file is ignored. "
117
162
  )
118
- return AsyncClient(
119
- auth=auth,
163
+ elif async_transport is None:
164
+ async_transport = AiohttpTransport(
120
165
  http2=enable_http2,
121
166
  verify=verify_ssl,
122
- timeout=timeout,
123
- transport=transport,
124
- **kwargs,
167
+ timeout=timeout, # type: ignore
168
+ )
169
+ logger.debug(
170
+ f"Async transport has been to {async_transport!r} by {APP_BRAND_NAME}."
125
171
  )
126
- return Client(
172
+ async_client = AsyncClient(
127
173
  auth=auth,
128
174
  http2=enable_http2,
129
175
  verify=verify_ssl,
130
176
  timeout=timeout,
177
+ transport=async_transport,
131
178
  **kwargs,
132
179
  )
133
- except TypeError as e:
134
- check_reserved_keyword(
135
- e,
136
- what=f"{APP_NAME}",
137
- against=f"class {SimpleClient.__name__}, "
138
- f"so the parameter remains user-configurable "
139
- f"through {CONFIG_FILE_NAME} configuration file",
180
+ if async_capacity is not None:
181
+ async_client._async_semaphore_ = asyncio.Semaphore(async_capacity)
182
+ logger.debug(
183
+ f"Async semaphore is set to {async_client.__class__.__name__} "
184
+ f"instance {async_client!r}: {async_capacity}"
185
+ )
186
+ return async_client
187
+ if sync_transport is not None:
188
+ logger.debug(
189
+ f"Sync transport has been set to {sync_transport!r} by an external method."
140
190
  )
141
- raise e
191
+ return Client(
192
+ auth=auth,
193
+ http2=enable_http2,
194
+ verify=verify_ssl,
195
+ timeout=timeout,
196
+ transport=sync_transport,
197
+ **kwargs,
198
+ )
142
199
 
143
200
 
144
201
  class GlobalSharedSession:
@@ -172,13 +229,23 @@ class GlobalSharedSession:
172
229
  @cached_property
173
230
  def sync_client(self) -> Optional[Client]:
174
231
  if self.limited_to in ("sync", "all"):
175
- return SimpleClient(is_async_client=False, **self._kwargs)
232
+ client = SimpleClient(is_async_client=False, **self._kwargs)
233
+ logger.debug(
234
+ f"{GlobalSharedSession.__name__} instance {self!r} "
235
+ f"injected sync client {client!r}."
236
+ )
237
+ return client
176
238
  return None
177
239
 
178
240
  @cached_property
179
241
  def async_client(self) -> Optional[AsyncClient]:
180
242
  if self.limited_to in ("async", "all"):
181
- return SimpleClient(is_async_client=True, **self._kwargs)
243
+ client = SimpleClient(is_async_client=True, **self._kwargs)
244
+ logger.debug(
245
+ f"{GlobalSharedSession.__name__} instance {self!r} "
246
+ f"injected async client {client!r}."
247
+ )
248
+ return client
182
249
  return None
183
250
 
184
251
  def close(self) -> None:
@@ -186,6 +253,9 @@ class GlobalSharedSession:
186
253
  if self.sync_client is not None:
187
254
  if self.sync_client.is_closed is False:
188
255
  self.sync_client.close()
256
+ logger.debug(
257
+ f"{self.__class__.__name__} has closed sync client {self.sync_client!r}."
258
+ )
189
259
  if self.async_client is not None:
190
260
  if self.async_client.is_closed is False:
191
261
  # nest_asyncio is needed if there are multiple asyncio.run.
@@ -209,8 +279,15 @@ class GlobalSharedSession:
209
279
  ) from e
210
280
  else:
211
281
  event_loop.close()
282
+ logger.debug(
283
+ f"{self.__class__.__name__} has closed async client {self.async_client!r}."
284
+ )
212
285
  else:
213
286
  event_loop.create_task(self.async_client.aclose())
287
+ logger.debug(
288
+ f"{self.__class__.__name__} has added a task for async client "
289
+ f"{self.async_client!r} to be closed."
290
+ )
214
291
 
215
292
  def __enter__(self):
216
293
  self._outer_instance = GlobalSharedSession._instance
@@ -339,6 +416,7 @@ class APIRequest(ABC):
339
416
  return NotImplemented
340
417
  if not self.client.is_closed:
341
418
  self.client.close()
419
+ logger.debug(f"Sync client {self.client!r} is closed.")
342
420
  return None
343
421
 
344
422
  @abstractmethod
@@ -347,6 +425,7 @@ class APIRequest(ABC):
347
425
  return NotImplemented
348
426
  if not self.client.is_closed:
349
427
  await self.client.aclose()
428
+ logger.debug(f"Async client {self.client!r} is closed.")
350
429
  return None
351
430
 
352
431
  @abstractmethod
@@ -721,6 +800,14 @@ class AsyncGETRequest(APIRequest, is_async_client=True):
721
800
  url = ElabFTWURL(
722
801
  endpoint_name, endpoint_id, sub_endpoint_name, sub_endpoint_id, query
723
802
  )
803
+ client = super().client
804
+ if async_semaphore := getattr(client, "_async_semaphore_", None):
805
+ async with async_semaphore:
806
+ return await client.get(
807
+ url.get(),
808
+ headers=headers or {"Accept": "application/json"},
809
+ **kwargs,
810
+ )
724
811
  return await super().client.get(
725
812
  url.get(), headers=headers or {"Accept": "application/json"}, **kwargs
726
813
  )
@@ -112,7 +112,7 @@ class FixedAsyncEndpoint:
112
112
  )
113
113
 
114
114
  async def aclose(self) -> Optional[NotImplementedType]:
115
- if self._is_global_shared_instance_none is False:
115
+ if self._is_global_shared_instance_none is True:
116
116
  await self._client.aclose()
117
117
  return NotImplemented
118
118