mlrun 1.6.0rc21__py3-none-any.whl → 1.6.0rc22__py3-none-any.whl

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.

Potentially problematic release.


This version of mlrun might be problematic. Click here for more details.

Files changed (45) hide show
  1. mlrun/artifacts/base.py +6 -6
  2. mlrun/artifacts/dataset.py +15 -8
  3. mlrun/artifacts/manager.py +1 -1
  4. mlrun/artifacts/model.py +2 -2
  5. mlrun/artifacts/plots.py +8 -8
  6. mlrun/datastore/azure_blob.py +9 -14
  7. mlrun/datastore/base.py +21 -7
  8. mlrun/datastore/dbfs_store.py +10 -10
  9. mlrun/datastore/filestore.py +2 -1
  10. mlrun/datastore/google_cloud_storage.py +9 -8
  11. mlrun/datastore/redis.py +2 -1
  12. mlrun/datastore/s3.py +3 -6
  13. mlrun/datastore/sources.py +2 -12
  14. mlrun/datastore/targets.py +2 -13
  15. mlrun/datastore/v3io.py +16 -19
  16. mlrun/db/httpdb.py +8 -1
  17. mlrun/execution.py +14 -5
  18. mlrun/feature_store/api.py +3 -4
  19. mlrun/launcher/base.py +4 -4
  20. mlrun/lists.py +0 -6
  21. mlrun/model.py +8 -1
  22. mlrun/model_monitoring/api.py +9 -31
  23. mlrun/model_monitoring/batch.py +14 -13
  24. mlrun/model_monitoring/controller.py +91 -69
  25. mlrun/model_monitoring/controller_handler.py +1 -3
  26. mlrun/model_monitoring/helpers.py +19 -8
  27. mlrun/model_monitoring/stream_processing.py +0 -3
  28. mlrun/projects/operations.py +1 -1
  29. mlrun/projects/project.py +5 -4
  30. mlrun/runtimes/base.py +6 -1
  31. mlrun/runtimes/constants.py +11 -0
  32. mlrun/runtimes/kubejob.py +1 -1
  33. mlrun/runtimes/local.py +64 -53
  34. mlrun/serving/routers.py +7 -20
  35. mlrun/serving/server.py +4 -14
  36. mlrun/serving/utils.py +0 -3
  37. mlrun/utils/helpers.py +5 -2
  38. mlrun/utils/logger.py +5 -5
  39. mlrun/utils/version/version.json +2 -2
  40. {mlrun-1.6.0rc21.dist-info → mlrun-1.6.0rc22.dist-info}/METADATA +3 -1
  41. {mlrun-1.6.0rc21.dist-info → mlrun-1.6.0rc22.dist-info}/RECORD +45 -45
  42. {mlrun-1.6.0rc21.dist-info → mlrun-1.6.0rc22.dist-info}/LICENSE +0 -0
  43. {mlrun-1.6.0rc21.dist-info → mlrun-1.6.0rc22.dist-info}/WHEEL +0 -0
  44. {mlrun-1.6.0rc21.dist-info → mlrun-1.6.0rc22.dist-info}/entry_points.txt +0 -0
  45. {mlrun-1.6.0rc21.dist-info → mlrun-1.6.0rc22.dist-info}/top_level.txt +0 -0
@@ -28,6 +28,7 @@ from mlrun.utils import logger
28
28
 
29
29
  if typing.TYPE_CHECKING:
30
30
  from mlrun.db.base import RunDBInterface
31
+ from mlrun.projects import MlrunProject
31
32
 
32
33
 
33
34
  class _BatchDict(typing.TypedDict):
@@ -36,6 +37,10 @@ class _BatchDict(typing.TypedDict):
36
37
  days: int
37
38
 
38
39
 
40
+ class _MLRunNoRunsFoundError(Exception):
41
+ pass
42
+
43
+
39
44
  def get_stream_path(project: str = None, application_name: str = None):
40
45
  """
41
46
  Get stream path from the project secret. If wasn't set, take it from the system configurations
@@ -63,24 +68,22 @@ def get_stream_path(project: str = None, application_name: str = None):
63
68
 
64
69
 
65
70
  def get_monitoring_parquet_path(
66
- project: str,
71
+ project: "MlrunProject",
67
72
  kind: str = mlrun.common.schemas.model_monitoring.FileTargetKind.PARQUET,
68
73
  ) -> str:
69
74
  """Get model monitoring parquet target for the current project and kind. The parquet target path is based on the
70
75
  project artifact path. If project artifact path is not defined, the parquet target path will be based on MLRun
71
76
  artifact path.
72
77
 
73
- :param project: Project name.
78
+ :param project: Project object.
74
79
  :param kind: indicate the kind of the parquet path, can be either stream_parquet or stream_controller_parquet
75
80
 
76
81
  :return: Monitoring parquet target path.
77
82
  """
78
-
79
- project_obj = mlrun.get_or_create_project(name=project)
80
- artifact_path = project_obj.spec.artifact_path
83
+ artifact_path = project.spec.artifact_path
81
84
  # Generate monitoring parquet path value
82
85
  parquet_path = mlrun.mlconf.get_model_monitoring_file_target_path(
83
- project=project,
86
+ project=project.name,
84
87
  kind=kind,
85
88
  target="offline",
86
89
  artifact_path=artifact_path,
@@ -132,7 +135,7 @@ def _get_monitoring_time_window_from_controller_run(
132
135
  run_name = MonitoringFunctionNames.APPLICATION_CONTROLLER
133
136
  runs = db.list_runs(project=project, name=run_name, sort=True)
134
137
  if not runs:
135
- raise MLRunValueError(f"No {run_name} runs were found")
138
+ raise _MLRunNoRunsFoundError(f"No {run_name} runs were found")
136
139
  last_run = runs[0]
137
140
  try:
138
141
  batch_dict = last_run["spec"]["parameters"]["batch_intervals_dict"]
@@ -162,9 +165,17 @@ def bump_model_endpoint_last_request(
162
165
  endpoint_id=model_endpoint.metadata.uid,
163
166
  )
164
167
  raise MLRunValueError("Model endpoint last request time is empty")
168
+ try:
169
+ time_window = _get_monitoring_time_window_from_controller_run(project, db)
170
+ except _MLRunNoRunsFoundError:
171
+ logger.debug(
172
+ "Not bumping model endpoint last request time - no controller runs were found"
173
+ )
174
+ return
175
+
165
176
  bumped_last_request = (
166
177
  datetime.datetime.fromisoformat(model_endpoint.status.last_request)
167
- + _get_monitoring_time_window_from_controller_run(project, db)
178
+ + time_window
168
179
  + datetime.timedelta(
169
180
  seconds=mlrun.mlconf.model_endpoint_monitoring.parquet_batching_timeout_secs
170
181
  )
@@ -528,9 +528,6 @@ class ProcessBeforeTSDB(mlrun.feature_store.steps.MapClass):
528
528
 
529
529
  # Getting event timestamp and endpoint_id
530
530
  base_event = {k: event[k] for k in base_fields}
531
- base_event[EventFieldType.TIMESTAMP] = datetime.datetime.fromisoformat(
532
- base_event[EventFieldType.TIMESTAMP]
533
- )
534
531
 
535
532
  # base_metrics includes the stats about the average latency and the amount of predictions over time
536
533
  base_metrics = {
@@ -274,7 +274,7 @@ def build_function(
274
274
  if not overwrite_build_params:
275
275
  # TODO: change overwrite_build_params default to True in 1.8.0
276
276
  warnings.warn(
277
- "The `overwrite_build_params` parameter default will change from 'False' to 'True in 1.8.0.",
277
+ "The `overwrite_build_params` parameter default will change from 'False' to 'True' in 1.8.0.",
278
278
  mlrun.utils.OverwriteBuildParamsWarning,
279
279
  )
280
280
 
mlrun/projects/project.py CHANGED
@@ -2167,7 +2167,7 @@ class MlrunProject(ModelObj):
2167
2167
  self.spec.remove_function(name)
2168
2168
 
2169
2169
  def remove_model_monitoring_function(self, name):
2170
- """remove the specified model-monitoring-app function from the project
2170
+ """remove the specified model-monitoring-app function from the project and from the db
2171
2171
 
2172
2172
  :param name: name of the model-monitoring-app function (under the project)
2173
2173
  """
@@ -2177,6 +2177,7 @@ class MlrunProject(ModelObj):
2177
2177
  == mm_constants.ModelMonitoringAppLabel.VAL
2178
2178
  ):
2179
2179
  self.remove_function(name=name)
2180
+ mlrun.db.get_run_db().delete_function(name=name.lower())
2180
2181
  logger.info(f"{name} function has been removed from {self.name} project")
2181
2182
  else:
2182
2183
  raise logger.error(
@@ -3016,7 +3017,7 @@ class MlrunProject(ModelObj):
3016
3017
  if not overwrite_build_params:
3017
3018
  # TODO: change overwrite_build_params default to True in 1.8.0
3018
3019
  warnings.warn(
3019
- "The `overwrite_build_params` parameter default will change from 'False' to 'True in 1.8.0.",
3020
+ "The `overwrite_build_params` parameter default will change from 'False' to 'True' in 1.8.0.",
3020
3021
  mlrun.utils.OverwriteBuildParamsWarning,
3021
3022
  )
3022
3023
  default_image_name = mlrun.mlconf.default_project_image_name.format(
@@ -3102,7 +3103,7 @@ class MlrunProject(ModelObj):
3102
3103
  if not overwrite_build_params:
3103
3104
  # TODO: change overwrite_build_params default to True in 1.8.0
3104
3105
  warnings.warn(
3105
- "The `overwrite_build_params` parameter default will change from 'False' to 'True in 1.8.0.",
3106
+ "The `overwrite_build_params` parameter default will change from 'False' to 'True' in 1.8.0.",
3106
3107
  mlrun.utils.OverwriteBuildParamsWarning,
3107
3108
  )
3108
3109
 
@@ -3407,7 +3408,7 @@ class MlrunProject(ModelObj):
3407
3408
  :param state: List only runs whose state is specified.
3408
3409
  :param sort: Whether to sort the result according to their start time. Otherwise, results will be
3409
3410
  returned by their internal order in the DB (order will not be guaranteed).
3410
- :param last: Deprecated - currently not used.
3411
+ :param last: Deprecated - currently not used (will be removed in 1.8.0).
3411
3412
  :param iter: If ``True`` return runs from all iterations. Otherwise, return only runs whose ``iter`` is 0.
3412
3413
  :param start_time_from: Filter by run start time in ``[start_time_from, start_time_to]``.
3413
3414
  :param start_time_to: Filter by run start time in ``[start_time_from, start_time_to]``.
mlrun/runtimes/base.py CHANGED
@@ -550,7 +550,12 @@ class BaseRuntime(ModelObj):
550
550
  if err:
551
551
  updates["status.error"] = err_to_str(err)
552
552
 
553
- elif not was_none and last_state != "completed":
553
+ elif (
554
+ not was_none
555
+ and last_state != mlrun.runtimes.constants.RunStates.completed
556
+ and last_state
557
+ not in mlrun.runtimes.constants.RunStates.error_and_abortion_states()
558
+ ):
554
559
  try:
555
560
  runtime_cls = mlrun.runtimes.get_runtime_class(kind)
556
561
  updates = runtime_cls._get_run_completion_updates(resp)
@@ -165,6 +165,17 @@ class RunStates(object):
165
165
  RunStates.aborted,
166
166
  ]
167
167
 
168
+ @staticmethod
169
+ def abortion_states():
170
+ return [
171
+ RunStates.aborted,
172
+ RunStates.aborting,
173
+ ]
174
+
175
+ @staticmethod
176
+ def error_and_abortion_states():
177
+ return list(set(RunStates.error_states()) | set(RunStates.abortion_states()))
178
+
168
179
  @staticmethod
169
180
  def non_terminal_states():
170
181
  return list(set(RunStates.all()) - set(RunStates.terminal_states()))
mlrun/runtimes/kubejob.py CHANGED
@@ -134,7 +134,7 @@ class KubejobRuntime(KubeResource):
134
134
  if not overwrite:
135
135
  # TODO: change overwrite default to True in 1.8.0
136
136
  warnings.warn(
137
- "The `overwrite` parameter default will change from 'False' to 'True in 1.8.0.",
137
+ "The `overwrite` parameter default will change from 'False' to 'True' in 1.8.0.",
138
138
  mlrun.utils.OverwriteBuildParamsWarning,
139
139
  )
140
140
  image = mlrun.utils.helpers.remove_image_protocol_prefix(image)
mlrun/runtimes/local.py CHANGED
@@ -307,7 +307,7 @@ class LocalRuntime(BaseRuntime, ParallelRunner):
307
307
 
308
308
  # if RunError was raised it means that the error was raised as part of running the function
309
309
  # ( meaning the state was already updated to error ) therefore we just re-raise the error
310
- except (RunError, mlrun.errors.MLRunTaskCancelledError) as err:
310
+ except RunError as err:
311
311
  raise err
312
312
  # this exception handling is for the case where we fail on pre-loading or post-running the function
313
313
  # and the state was not updated to error yet, therefore we update the state to error and raise as RunError
@@ -451,58 +451,69 @@ class _DupStdout(object):
451
451
 
452
452
  def exec_from_params(handler, runobj: RunObject, context: MLClientCtx, cwd=None):
453
453
  old_level = logger.level
454
- if runobj.spec.verbose:
455
- logger.set_logger_level("DEBUG")
456
-
457
- # Prepare the inputs type hints (user may pass type hints as part of the input keys):
458
- runobj.spec.extract_type_hints_from_inputs()
459
- # Read the keyword arguments to pass to the function (combining params and inputs from the run spec):
460
- kwargs = get_func_arg(handler, runobj, context)
461
-
462
- stdout = _DupStdout()
463
- err = ""
464
- val = None
465
- old_dir = os.getcwd()
466
- with redirect_stdout(stdout):
467
- context.set_logger_stream(stdout)
468
- try:
469
- if cwd:
470
- os.chdir(cwd)
471
- # Apply the MLRun handler decorator for parsing inputs using type hints and logging outputs using log hints
472
- # (Expected behavior: inputs are being parsed when they have type hints in code or given by user. Outputs
473
- # are logged only if log hints are provided by the user):
474
- if mlrun.mlconf.packagers.enabled:
475
- val = mlrun.handler(
476
- inputs=(
477
- runobj.spec.inputs_type_hints
478
- if runobj.spec.inputs_type_hints
479
- else True # True will use type hints if provided in user's code.
480
- ),
481
- outputs=(
482
- runobj.spec.returns
483
- if runobj.spec.returns
484
- else None # None will turn off outputs logging.
485
- ),
486
- )(handler)(**kwargs)
487
- else:
488
- val = handler(**kwargs)
489
- context.set_state("completed", commit=False)
490
- except Exception as exc:
491
- err = err_to_str(exc)
492
- logger.error(f"Execution error, {traceback.format_exc()}")
493
- context.set_state(error=err, commit=False)
494
- logger.set_logger_level(old_level)
495
-
496
- stdout.flush()
497
- if cwd:
498
- os.chdir(old_dir)
499
- context.set_logger_stream(sys.stdout)
500
- if val:
501
- context.log_result("return", val)
502
-
503
- # completion will be ignored if error is set
504
- context.commit(completed=True)
505
- logger.set_logger_level(old_level)
454
+ try:
455
+ if runobj.spec.verbose:
456
+ logger.set_logger_level("DEBUG")
457
+
458
+ # Prepare the inputs type hints (user may pass type hints as part of the input keys):
459
+ runobj.spec.extract_type_hints_from_inputs()
460
+ # Read the keyword arguments to pass to the function (combining params and inputs from the run spec):
461
+ kwargs = get_func_arg(handler, runobj, context)
462
+
463
+ stdout = _DupStdout()
464
+ err = ""
465
+ val = None
466
+ old_dir = os.getcwd()
467
+ commit = True
468
+ with redirect_stdout(stdout):
469
+ context.set_logger_stream(stdout)
470
+ try:
471
+ if cwd:
472
+ os.chdir(cwd)
473
+ # Apply the MLRun handler decorator for parsing inputs using type hints and logging outputs using
474
+ # log hints (Expected behavior: inputs are being parsed when they have type hints in code or given
475
+ # by user. Outputs are logged only if log hints are provided by the user):
476
+ if mlrun.mlconf.packagers.enabled:
477
+ val = mlrun.handler(
478
+ inputs=(
479
+ runobj.spec.inputs_type_hints
480
+ if runobj.spec.inputs_type_hints
481
+ else True # True will use type hints if provided in user's code.
482
+ ),
483
+ outputs=(
484
+ runobj.spec.returns
485
+ if runobj.spec.returns
486
+ else None # None will turn off outputs logging.
487
+ ),
488
+ )(handler)(**kwargs)
489
+ else:
490
+ val = handler(**kwargs)
491
+ context.set_state("completed", commit=False)
492
+ except mlrun.errors.MLRunTaskCancelledError as exc:
493
+ logger.warning("Run was aborted", err=err_to_str(exc))
494
+ # Run was aborted, the state run state is updated by the abort job, no need to commit again
495
+ context.set_state(
496
+ mlrun.runtimes.constants.RunStates.aborted, commit=False
497
+ )
498
+ commit = False
499
+ except Exception as exc:
500
+ err = err_to_str(exc)
501
+ logger.error(f"Execution error, {traceback.format_exc()}")
502
+ context.set_state(error=err, commit=False)
503
+
504
+ stdout.flush()
505
+ if cwd:
506
+ os.chdir(old_dir)
507
+ context.set_logger_stream(sys.stdout)
508
+ if val:
509
+ context.log_result("return", val)
510
+
511
+ if commit:
512
+ # completion will be ignored if error is set
513
+ context.commit(completed=True)
514
+
515
+ finally:
516
+ logger.set_logger_level(old_level)
506
517
  return stdout.buf.getvalue(), err
507
518
 
508
519
 
mlrun/serving/routers.py CHANGED
@@ -13,9 +13,11 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import concurrent
16
+ import concurrent.futures
16
17
  import copy
17
18
  import json
18
19
  import traceback
20
+ import typing
19
21
  from enum import Enum
20
22
  from io import BytesIO
21
23
  from typing import Dict, List, Union
@@ -209,16 +211,6 @@ class ModelRouter(BaseModelRouter):
209
211
  return event
210
212
 
211
213
 
212
- class ExecutorTypes:
213
- # TODO: Remove in 1.5.0.
214
- thread = "thread"
215
- process = "process"
216
-
217
- @staticmethod
218
- def all():
219
- return [ExecutorTypes.thread, ExecutorTypes.process]
220
-
221
-
222
214
  class ParallelRunnerModes(str, Enum):
223
215
  """Supported parallel running modes for VotingEnsemble"""
224
216
 
@@ -313,17 +305,12 @@ class ParallelRun(BaseModelRouter):
313
305
  )
314
306
  self.name = name or "ParallelRun"
315
307
  self.extend_event = extend_event
316
- if isinstance(executor_type, ExecutorTypes):
317
- executor_type = str(executor_type)
318
- logger.warn(
319
- "ExecutorTypes is deprecated and will be removed in 1.5.0, use ParallelRunnerModes instead",
320
- # TODO: In 1.5.0 to remove ExecutorTypes
321
- FutureWarning,
322
- )
323
308
  self.executor_type = ParallelRunnerModes(executor_type)
324
- self._pool: Union[
325
- concurrent.futures.ProcessPoolExecutor,
326
- concurrent.futures.ThreadPoolExecutor,
309
+ self._pool: typing.Optional[
310
+ Union[
311
+ concurrent.futures.ProcessPoolExecutor,
312
+ concurrent.futures.ThreadPoolExecutor,
313
+ ]
327
314
  ] = None
328
315
 
329
316
  def _apply_logic(self, results: dict, event=None):
mlrun/serving/server.py CHANGED
@@ -40,8 +40,6 @@ from .states import RootFlowStep, RouterStep, get_function, graph_root_setter
40
40
  from .utils import (
41
41
  event_id_key,
42
42
  event_path_key,
43
- legacy_event_id_key,
44
- legacy_event_path_key,
45
43
  )
46
44
 
47
45
 
@@ -257,18 +255,10 @@ class GraphServer(ModelObj):
257
255
  context = context or server_context
258
256
  event.content_type = event.content_type or self.default_content_type or ""
259
257
  if event.headers:
260
- # TODO: remove old event id and path keys in 1.6.0
261
- if event_id_key in event.headers or legacy_event_id_key in event.headers:
262
- event.id = event.headers.get(event_id_key) or event.headers.get(
263
- legacy_event_id_key
264
- )
265
- if (
266
- event_path_key in event.headers
267
- or legacy_event_path_key in event.headers
268
- ):
269
- event.path = event.headers.get(event_path_key) or event.headers.get(
270
- legacy_event_path_key
271
- )
258
+ if event_id_key in event.headers:
259
+ event.id = event.headers.get(event_id_key)
260
+ if event_path_key in event.headers:
261
+ event.path = event.headers.get(event_path_key)
272
262
 
273
263
  if isinstance(event.body, (str, bytes)) and (
274
264
  not event.content_type or event.content_type in ["json", "application/json"]
mlrun/serving/utils.py CHANGED
@@ -21,9 +21,6 @@ from mlrun.utils import get_in, update_in
21
21
  # more info https://github.com/benoitc/gunicorn/issues/2799, this comment can be removed once old keys are removed
22
22
  event_id_key = "MLRUN-EVENT-ID"
23
23
  event_path_key = "MLRUN-EVENT-PATH"
24
- # TODO: remove these keys in 1.6.0
25
- legacy_event_id_key = "MLRUN_EVENT_ID"
26
- legacy_event_path_key = "MLRUN_EVENT_PATH"
27
24
 
28
25
 
29
26
  def _extract_input_data(input_path, body):
mlrun/utils/helpers.py CHANGED
@@ -394,8 +394,11 @@ def get_pretty_types_names(types):
394
394
  return types[0].__name__
395
395
 
396
396
 
397
- def now_date():
398
- return datetime.now(timezone.utc)
397
+ def now_date(tz: timezone = timezone.utc) -> datetime:
398
+ return datetime.now(tz=tz)
399
+
400
+
401
+ datetime_now = now_date
399
402
 
400
403
 
401
404
  def to_date_str(d):
mlrun/utils/logger.py CHANGED
@@ -131,10 +131,10 @@ class Logger(object):
131
131
  def level(self):
132
132
  return self._logger.level
133
133
 
134
- def set_logger_level(self, level: Union[str, int]):
134
+ def set_logger_level(self, level: Union[str, int]) -> None:
135
135
  self._logger.setLevel(level)
136
136
 
137
- def replace_handler_stream(self, handler_name: str, file: IO[str]):
137
+ def replace_handler_stream(self, handler_name: str, file: IO[str]) -> None:
138
138
  for handler in self._logger.handlers:
139
139
  if handler.name == handler_name:
140
140
  handler.stream = file
@@ -193,11 +193,11 @@ def _create_formatter_instance(formatter_kind: FormatterKinds) -> logging.Format
193
193
 
194
194
 
195
195
  def create_logger(
196
- level: str = None,
196
+ level: Optional[str] = None,
197
197
  formatter_kind: str = FormatterKinds.HUMAN.name,
198
198
  name: str = "mlrun",
199
- stream=stdout,
200
- ):
199
+ stream: IO[str] = stdout,
200
+ ) -> Logger:
201
201
  level = level or config.log_level or "info"
202
202
 
203
203
  level = logging.getLevelName(level.upper())
@@ -1,4 +1,4 @@
1
1
  {
2
- "git_commit": "0ac528699a3a7c93f4d4c29e631dd8444b7c86c0",
3
- "version": "1.6.0-rc21"
2
+ "git_commit": "ae3fdb8b9ce822eafb3a810f2a44faf8a5d810ca",
3
+ "version": "1.6.0-rc22"
4
4
  }
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mlrun
3
- Version: 1.6.0rc21
3
+ Version: 1.6.0rc22
4
4
  Summary: Tracking and config of machine learning runs
5
5
  Home-page: https://github.com/mlrun/mlrun
6
6
  Author: Yaron Haviv
7
7
  Author-email: yaronh@iguazio.com
8
8
  License: Apache License 2.0
9
+ Keywords: mlrun,mlops,data-science,machine-learning,experiment-tracking
9
10
  Classifier: Development Status :: 4 - Beta
10
11
  Classifier: Intended Audience :: Developers
11
12
  Classifier: License :: OSI Approved :: Apache Software License
@@ -18,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.9
18
19
  Classifier: Programming Language :: Python
19
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
21
  Classifier: Topic :: Software Development :: Libraries
22
+ Requires-Python: >=3.9, <3.12
21
23
  Description-Content-Type: text/markdown
22
24
  License-File: LICENSE
23
25
  Requires-Dist: urllib3 <1.27,>=1.26.9