guidellm 0.3.1__py3-none-any.whl → 0.6.0a5__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.
Files changed (141) hide show
  1. guidellm/__init__.py +5 -2
  2. guidellm/__main__.py +524 -255
  3. guidellm/backends/__init__.py +33 -0
  4. guidellm/backends/backend.py +109 -0
  5. guidellm/backends/openai.py +340 -0
  6. guidellm/backends/response_handlers.py +428 -0
  7. guidellm/benchmark/__init__.py +69 -39
  8. guidellm/benchmark/benchmarker.py +160 -316
  9. guidellm/benchmark/entrypoints.py +560 -127
  10. guidellm/benchmark/outputs/__init__.py +24 -0
  11. guidellm/benchmark/outputs/console.py +633 -0
  12. guidellm/benchmark/outputs/csv.py +721 -0
  13. guidellm/benchmark/outputs/html.py +473 -0
  14. guidellm/benchmark/outputs/output.py +169 -0
  15. guidellm/benchmark/outputs/serialized.py +69 -0
  16. guidellm/benchmark/profiles.py +718 -0
  17. guidellm/benchmark/progress.py +553 -556
  18. guidellm/benchmark/scenarios/__init__.py +40 -0
  19. guidellm/benchmark/scenarios/chat.json +6 -0
  20. guidellm/benchmark/scenarios/rag.json +6 -0
  21. guidellm/benchmark/schemas/__init__.py +66 -0
  22. guidellm/benchmark/schemas/base.py +402 -0
  23. guidellm/benchmark/schemas/generative/__init__.py +55 -0
  24. guidellm/benchmark/schemas/generative/accumulator.py +841 -0
  25. guidellm/benchmark/schemas/generative/benchmark.py +163 -0
  26. guidellm/benchmark/schemas/generative/entrypoints.py +381 -0
  27. guidellm/benchmark/schemas/generative/metrics.py +927 -0
  28. guidellm/benchmark/schemas/generative/report.py +158 -0
  29. guidellm/data/__init__.py +34 -4
  30. guidellm/data/builders.py +541 -0
  31. guidellm/data/collators.py +16 -0
  32. guidellm/data/config.py +120 -0
  33. guidellm/data/deserializers/__init__.py +49 -0
  34. guidellm/data/deserializers/deserializer.py +141 -0
  35. guidellm/data/deserializers/file.py +223 -0
  36. guidellm/data/deserializers/huggingface.py +94 -0
  37. guidellm/data/deserializers/memory.py +194 -0
  38. guidellm/data/deserializers/synthetic.py +246 -0
  39. guidellm/data/entrypoints.py +52 -0
  40. guidellm/data/loaders.py +190 -0
  41. guidellm/data/preprocessors/__init__.py +27 -0
  42. guidellm/data/preprocessors/formatters.py +410 -0
  43. guidellm/data/preprocessors/mappers.py +196 -0
  44. guidellm/data/preprocessors/preprocessor.py +30 -0
  45. guidellm/data/processor.py +29 -0
  46. guidellm/data/schemas.py +175 -0
  47. guidellm/data/utils/__init__.py +6 -0
  48. guidellm/data/utils/dataset.py +94 -0
  49. guidellm/extras/__init__.py +4 -0
  50. guidellm/extras/audio.py +220 -0
  51. guidellm/extras/vision.py +242 -0
  52. guidellm/logger.py +2 -2
  53. guidellm/mock_server/__init__.py +8 -0
  54. guidellm/mock_server/config.py +84 -0
  55. guidellm/mock_server/handlers/__init__.py +17 -0
  56. guidellm/mock_server/handlers/chat_completions.py +280 -0
  57. guidellm/mock_server/handlers/completions.py +280 -0
  58. guidellm/mock_server/handlers/tokenizer.py +142 -0
  59. guidellm/mock_server/models.py +510 -0
  60. guidellm/mock_server/server.py +238 -0
  61. guidellm/mock_server/utils.py +302 -0
  62. guidellm/scheduler/__init__.py +69 -26
  63. guidellm/scheduler/constraints/__init__.py +49 -0
  64. guidellm/scheduler/constraints/constraint.py +325 -0
  65. guidellm/scheduler/constraints/error.py +411 -0
  66. guidellm/scheduler/constraints/factory.py +182 -0
  67. guidellm/scheduler/constraints/request.py +312 -0
  68. guidellm/scheduler/constraints/saturation.py +722 -0
  69. guidellm/scheduler/environments.py +252 -0
  70. guidellm/scheduler/scheduler.py +137 -368
  71. guidellm/scheduler/schemas.py +358 -0
  72. guidellm/scheduler/strategies.py +617 -0
  73. guidellm/scheduler/worker.py +413 -419
  74. guidellm/scheduler/worker_group.py +712 -0
  75. guidellm/schemas/__init__.py +65 -0
  76. guidellm/schemas/base.py +417 -0
  77. guidellm/schemas/info.py +188 -0
  78. guidellm/schemas/request.py +235 -0
  79. guidellm/schemas/request_stats.py +349 -0
  80. guidellm/schemas/response.py +124 -0
  81. guidellm/schemas/statistics.py +1018 -0
  82. guidellm/{config.py → settings.py} +31 -24
  83. guidellm/utils/__init__.py +71 -8
  84. guidellm/utils/auto_importer.py +98 -0
  85. guidellm/utils/cli.py +132 -5
  86. guidellm/utils/console.py +566 -0
  87. guidellm/utils/encoding.py +778 -0
  88. guidellm/utils/functions.py +159 -0
  89. guidellm/utils/hf_datasets.py +1 -2
  90. guidellm/utils/hf_transformers.py +4 -4
  91. guidellm/utils/imports.py +9 -0
  92. guidellm/utils/messaging.py +1118 -0
  93. guidellm/utils/mixins.py +115 -0
  94. guidellm/utils/random.py +3 -4
  95. guidellm/utils/registry.py +220 -0
  96. guidellm/utils/singleton.py +133 -0
  97. guidellm/utils/synchronous.py +159 -0
  98. guidellm/utils/text.py +163 -50
  99. guidellm/utils/typing.py +41 -0
  100. guidellm/version.py +2 -2
  101. guidellm-0.6.0a5.dist-info/METADATA +364 -0
  102. guidellm-0.6.0a5.dist-info/RECORD +109 -0
  103. guidellm/backend/__init__.py +0 -23
  104. guidellm/backend/backend.py +0 -259
  105. guidellm/backend/openai.py +0 -708
  106. guidellm/backend/response.py +0 -136
  107. guidellm/benchmark/aggregator.py +0 -760
  108. guidellm/benchmark/benchmark.py +0 -837
  109. guidellm/benchmark/output.py +0 -997
  110. guidellm/benchmark/profile.py +0 -409
  111. guidellm/benchmark/scenario.py +0 -104
  112. guidellm/data/prideandprejudice.txt.gz +0 -0
  113. guidellm/dataset/__init__.py +0 -22
  114. guidellm/dataset/creator.py +0 -213
  115. guidellm/dataset/entrypoints.py +0 -42
  116. guidellm/dataset/file.py +0 -92
  117. guidellm/dataset/hf_datasets.py +0 -62
  118. guidellm/dataset/in_memory.py +0 -132
  119. guidellm/dataset/synthetic.py +0 -287
  120. guidellm/objects/__init__.py +0 -18
  121. guidellm/objects/pydantic.py +0 -89
  122. guidellm/objects/statistics.py +0 -953
  123. guidellm/preprocess/__init__.py +0 -3
  124. guidellm/preprocess/dataset.py +0 -374
  125. guidellm/presentation/__init__.py +0 -28
  126. guidellm/presentation/builder.py +0 -27
  127. guidellm/presentation/data_models.py +0 -232
  128. guidellm/presentation/injector.py +0 -66
  129. guidellm/request/__init__.py +0 -18
  130. guidellm/request/loader.py +0 -284
  131. guidellm/request/request.py +0 -79
  132. guidellm/request/types.py +0 -10
  133. guidellm/scheduler/queues.py +0 -25
  134. guidellm/scheduler/result.py +0 -155
  135. guidellm/scheduler/strategy.py +0 -495
  136. guidellm-0.3.1.dist-info/METADATA +0 -329
  137. guidellm-0.3.1.dist-info/RECORD +0 -62
  138. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/WHEEL +0 -0
  139. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/entry_points.txt +0 -0
  140. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/licenses/LICENSE +0 -0
  141. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
- import os
3
4
  from collections.abc import Sequence
4
5
  from enum import Enum
5
- from typing import Literal, Optional
6
+ from typing import Literal
6
7
 
7
8
  from pydantic import BaseModel, Field, model_validator
8
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -31,8 +32,8 @@ class Environment(str, Enum):
31
32
 
32
33
 
33
34
  ENV_REPORT_MAPPING = {
34
- Environment.PROD: "https://blog.vllm.ai/guidellm/ui/v0.3.0/index.html",
35
- Environment.STAGING: "https://blog.vllm.ai/guidellm/ui/release/v0.3.0/index.html",
35
+ Environment.PROD: "https://blog.vllm.ai/guidellm/ui/v0.5.0/index.html",
36
+ Environment.STAGING: "https://blog.vllm.ai/guidellm/ui/release/v0.4.0/index.html",
36
37
  Environment.DEV: "https://blog.vllm.ai/guidellm/ui/dev/index.html",
37
38
  Environment.LOCAL: "http://localhost:3000/index.html",
38
39
  }
@@ -46,8 +47,8 @@ class LoggingSettings(BaseModel):
46
47
  disabled: bool = False
47
48
  clear_loggers: bool = True
48
49
  console_log_level: str = "WARNING"
49
- log_file: Optional[str] = None
50
- log_file_level: Optional[str] = None
50
+ log_file: str | None = None
51
+ log_file_level: str | None = None
51
52
 
52
53
 
53
54
  class DatasetSettings(BaseModel):
@@ -80,11 +81,11 @@ class OpenAISettings(BaseModel):
80
81
  for OpenAI server based pathways
81
82
  """
82
83
 
83
- api_key: Optional[str] = None
84
- bearer_token: Optional[str] = None
85
- headers: Optional[dict[str, str]] = None
86
- organization: Optional[str] = None
87
- project: Optional[str] = None
84
+ api_key: str | None = None
85
+ bearer_token: str | None = None
86
+ headers: dict[str, str] | None = None
87
+ organization: str | None = None
88
+ project: str | None = None
88
89
  base_url: str = "http://localhost:8000"
89
90
  max_output_tokens: int = 16384
90
91
  verify: bool = True
@@ -135,27 +136,33 @@ class Settings(BaseSettings):
135
136
  request_http2: bool = True
136
137
 
137
138
  # Scheduler settings
139
+ mp_context_type: Literal["spawn", "fork", "forkserver"] | None = "fork"
140
+ mp_serialization: Literal["dict", "sequence"] | None = "dict"
141
+ mp_encoding: (
142
+ Literal["msgpack", "msgspec"]
143
+ | None
144
+ | list[Literal["msgpack", "msgspec"] | None]
145
+ ) = ["msgspec", "msgpack", None]
146
+ mp_messaging_object: Literal["queue", "manager_queue", "pipe"] = "queue"
147
+ mp_requests_send_buffer_size: int = 1
148
+ mp_poll_interval: float = 0.1
149
+ mp_max_pending_buffer_percent: float = 0.5
150
+ mp_max_worker_buffer_percent: float = 0.2
138
151
  max_concurrency: int = 512
139
- max_worker_processes: int = Field(
140
- # use number of CPUs - 1, but at least 10
141
- default_factory=lambda: max((os.cpu_count() or 1) - 1, 10)
142
- )
143
- min_queued_requests: int = 20
144
- scheduler_start_delay: float = 5
152
+ max_worker_processes: int = 10
153
+ scheduler_start_delay_non_distributed: float = 1.0
154
+ constraint_error_window_size: float = 30
155
+ constraint_error_min_processed: float = 30
145
156
 
146
157
  # Data settings
147
158
  dataset: DatasetSettings = DatasetSettings()
148
159
 
149
160
  # Request/stats settings
150
- preferred_prompt_tokens_source: Optional[
151
- Literal["request", "response", "local"]
152
- ] = "response"
153
- preferred_output_tokens_source: Optional[
154
- Literal["request", "response", "local"]
155
- ] = "response"
161
+ preferred_prompt_tokens_source: Literal["request", "response"] = "response"
162
+ preferred_output_tokens_source: Literal["request", "response"] = "response"
156
163
  preferred_backend: Literal["openai"] = "openai"
157
164
  preferred_route: Literal["text_completions", "chat_completions"] = (
158
- "text_completions"
165
+ "chat_completions"
159
166
  )
160
167
  openai: OpenAISettings = OpenAISettings()
161
168
 
@@ -1,39 +1,102 @@
1
- from .colors import Colors
1
+ from .auto_importer import AutoImporterMixin
2
+ from .console import Colors, Console, ConsoleUpdateStep, StatusIcons, StatusStyles
2
3
  from .default_group import DefaultGroupHandler
3
4
  from .dict import recursive_key_update
4
- from .hf_datasets import (
5
- SUPPORTED_TYPES,
6
- save_dataset_to_file,
5
+ from .encoding import (
6
+ Encoder,
7
+ EncodingTypesAlias,
8
+ MessageEncoding,
9
+ SerializationTypesAlias,
10
+ Serializer,
7
11
  )
8
- from .hf_transformers import (
9
- check_load_processor,
12
+ from .functions import (
13
+ all_defined,
14
+ safe_add,
15
+ safe_divide,
16
+ safe_format_number,
17
+ safe_format_timestamp,
18
+ safe_getattr,
19
+ safe_multiply,
10
20
  )
21
+ from .hf_datasets import SUPPORTED_TYPES, save_dataset_to_file
22
+ from .hf_transformers import check_load_processor
23
+ from .imports import json
24
+ from .messaging import (
25
+ InterProcessMessaging,
26
+ InterProcessMessagingManagerQueue,
27
+ InterProcessMessagingPipe,
28
+ InterProcessMessagingQueue,
29
+ SendMessageT,
30
+ )
31
+ from .mixins import InfoMixin
11
32
  from .random import IntegerRangeSampler
33
+ from .registry import RegistryMixin, RegistryObjT
34
+ from .singleton import SingletonMixin, ThreadSafeSingletonMixin
35
+ from .synchronous import (
36
+ wait_for_sync_barrier,
37
+ wait_for_sync_event,
38
+ wait_for_sync_objects,
39
+ )
12
40
  from .text import (
13
41
  EndlessTextCreator,
14
42
  camelize_str,
15
43
  clean_text,
16
44
  filter_text,
17
- is_puncutation,
45
+ format_value_display,
46
+ is_punctuation,
18
47
  load_text,
19
48
  split_text,
20
49
  split_text_list_by_length,
21
50
  )
51
+ from .typing import get_literal_vals
22
52
 
23
53
  __all__ = [
24
54
  "SUPPORTED_TYPES",
55
+ "AutoImporterMixin",
25
56
  "Colors",
57
+ "Console",
58
+ "ConsoleUpdateStep",
26
59
  "DefaultGroupHandler",
60
+ "Encoder",
61
+ "EncodingTypesAlias",
27
62
  "EndlessTextCreator",
63
+ "InfoMixin",
28
64
  "IntegerRangeSampler",
65
+ "InterProcessMessaging",
66
+ "InterProcessMessagingManagerQueue",
67
+ "InterProcessMessagingPipe",
68
+ "InterProcessMessagingQueue",
69
+ "MessageEncoding",
70
+ "RegistryMixin",
71
+ "RegistryObjT",
72
+ "SendMessageT",
73
+ "SerializationTypesAlias",
74
+ "Serializer",
75
+ "SingletonMixin",
76
+ "StatusIcons",
77
+ "StatusStyles",
78
+ "ThreadSafeSingletonMixin",
79
+ "all_defined",
29
80
  "camelize_str",
30
81
  "check_load_processor",
31
82
  "clean_text",
32
83
  "filter_text",
33
- "is_puncutation",
84
+ "format_value_display",
85
+ "get_literal_vals",
86
+ "is_punctuation",
87
+ "json",
34
88
  "load_text",
35
89
  "recursive_key_update",
90
+ "safe_add",
91
+ "safe_divide",
92
+ "safe_format_number",
93
+ "safe_format_timestamp",
94
+ "safe_getattr",
95
+ "safe_multiply",
36
96
  "save_dataset_to_file",
37
97
  "split_text",
38
98
  "split_text_list_by_length",
99
+ "wait_for_sync_barrier",
100
+ "wait_for_sync_event",
101
+ "wait_for_sync_objects",
39
102
  ]
@@ -0,0 +1,98 @@
1
+ """
2
+ Automatic module importing utilities for dynamic class discovery.
3
+
4
+ This module provides a mixin class for automatic module importing within a package,
5
+ enabling dynamic discovery of classes and implementations without explicit imports.
6
+ It is particularly useful for auto-registering classes in a registry pattern where
7
+ subclasses need to be discoverable at runtime.
8
+
9
+ The AutoImporterMixin can be combined with registration mechanisms to create
10
+ extensible systems where new implementations are automatically discovered and
11
+ registered when they are placed in the correct package structure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import pkgutil
18
+ import sys
19
+ from typing import ClassVar
20
+
21
+ __all__ = ["AutoImporterMixin"]
22
+
23
+
24
+ class AutoImporterMixin:
25
+ """
26
+ Mixin class for automatic module importing within packages.
27
+
28
+ This mixin enables dynamic discovery of classes and implementations without
29
+ explicit imports by automatically importing all modules within specified
30
+ packages. It is designed for use with class registration mechanisms to enable
31
+ automatic discovery and registration of classes when they are placed in the
32
+ correct package structure.
33
+
34
+ Example:
35
+ ::
36
+ from guidellm.utils import AutoImporterMixin
37
+
38
+ class MyRegistry(AutoImporterMixin):
39
+ auto_package = "my_package.implementations"
40
+
41
+ MyRegistry.auto_import_package_modules()
42
+
43
+ :cvar auto_package: Package name or tuple of package names to import modules from
44
+ :cvar auto_ignore_modules: Module names to ignore during import
45
+ :cvar auto_imported_modules: List tracking which modules have been imported
46
+ """
47
+
48
+ auto_package: ClassVar[str | tuple[str, ...] | None] = None
49
+ auto_ignore_modules: ClassVar[tuple[str, ...] | None] = None
50
+ auto_imported_modules: ClassVar[list[str] | None] = None
51
+
52
+ @classmethod
53
+ def auto_import_package_modules(cls) -> None:
54
+ """
55
+ Automatically import all modules within the specified package(s).
56
+
57
+ Scans the package(s) defined in the `auto_package` class variable and imports
58
+ all modules found, tracking them in `auto_imported_modules`. Skips packages
59
+ (directories) and any modules listed in `auto_ignore_modules`.
60
+
61
+ :raises ValueError: If the `auto_package` class variable is not set
62
+ """
63
+ if cls.auto_package is None:
64
+ raise ValueError(
65
+ "The class variable 'auto_package' must be set to the package name to "
66
+ "import modules from."
67
+ )
68
+
69
+ cls.auto_imported_modules = []
70
+ packages = (
71
+ cls.auto_package
72
+ if isinstance(cls.auto_package, tuple)
73
+ else (cls.auto_package,)
74
+ )
75
+
76
+ for package_name in packages:
77
+ package = importlib.import_module(package_name)
78
+
79
+ for _, module_name, is_pkg in pkgutil.walk_packages(
80
+ package.__path__, package.__name__ + "."
81
+ ):
82
+ if (
83
+ is_pkg
84
+ or (
85
+ cls.auto_ignore_modules is not None
86
+ and module_name in cls.auto_ignore_modules
87
+ )
88
+ or module_name in cls.auto_imported_modules
89
+ ):
90
+ # Skip packages and ignored modules
91
+ continue
92
+
93
+ if module_name in sys.modules:
94
+ # Avoid circular imports
95
+ cls.auto_imported_modules.append(module_name)
96
+ else:
97
+ importlib.import_module(module_name)
98
+ cls.auto_imported_modules.append(module_name)
guidellm/utils/cli.py CHANGED
@@ -1,12 +1,116 @@
1
+ import contextlib
1
2
  import json
3
+ import os
2
4
  from typing import Any
3
5
 
4
6
  import click
5
7
 
8
+ __all__ = [
9
+ "Union",
10
+ "format_list_arg",
11
+ "list_set_env",
12
+ "parse_json",
13
+ "parse_list",
14
+ "parse_list_floats",
15
+ "set_if_not_default",
16
+ ]
6
17
 
7
- def parse_json(ctx, param, value): # noqa: ARG001
8
- if value is None:
18
+
19
+ def list_set_env(prefix: str = "GUIDELLM_") -> list[str]:
20
+ """
21
+ List all set environment variables prefixed with the given prefix.
22
+
23
+ :param prefix: The prefix to filter environment variables.
24
+ :return: List of environment variable names that are set with the given prefix.
25
+ """
26
+ return [key for key in os.environ if key.startswith(prefix)]
27
+
28
+
29
+ def parse_list(ctx, param, value) -> list[str] | None:
30
+ """
31
+ Click callback to parse the input value into a list of strings.
32
+ Supports single strings, comma-separated strings,
33
+ and lists/tuples of any of these formats (when multiple=True is used).
34
+
35
+ :param ctx: Click context
36
+ :param param: Click parameter
37
+ :param value: The input value to parse
38
+ :return: List of parsed strings
39
+ """
40
+ if value is None or value == [None]:
41
+ # Handle null values directly or nested null (when multiple=True)
9
42
  return None
43
+
44
+ if isinstance(value, list | tuple):
45
+ # Handle multiple=True case by recursively parsing each item and combining
46
+ parsed = []
47
+ for val in value:
48
+ if (items := parse_list(ctx, param, val)) is not None:
49
+ parsed.extend(items)
50
+ return parsed
51
+
52
+ if isinstance(value, str) and "," in value:
53
+ # Handle comma-separated strings
54
+ return [item.strip() for item in value.split(",") if item.strip()]
55
+
56
+ if isinstance(value, str):
57
+ # Handle single string
58
+ return [value.strip()]
59
+
60
+ # Fall back to returning as a single-item list
61
+ return [value]
62
+
63
+
64
+ def parse_list_floats(ctx, param, value):
65
+ str_list = parse_list(ctx, param, value)
66
+ if str_list is None:
67
+ return None
68
+
69
+ item = None # For error reporting
70
+ try:
71
+ return [float(item) for item in str_list]
72
+ except ValueError as err:
73
+ # Raise a Click error if any part isn't a valid float
74
+ raise click.BadParameter(
75
+ f"Input '{value}' is not a valid comma-separated list "
76
+ f"of floats/ints. Failed on {item} Error: {err}"
77
+ ) from err
78
+
79
+
80
+ def parse_json(ctx, param, value): # noqa: ARG001, C901, PLR0911
81
+ if isinstance(value, dict):
82
+ return value
83
+
84
+ if value is None or value == [None]:
85
+ return None
86
+
87
+ if isinstance(value, str) and not value.strip():
88
+ return None
89
+
90
+ if isinstance(value, list | tuple):
91
+ return [parse_json(ctx, param, val) for val in value]
92
+
93
+ if "{" not in value and "}" not in value and "=" in value:
94
+ # Treat it as a key=value pair if it doesn't look like JSON.
95
+ result = {}
96
+ for pair in value.split(","):
97
+ if "=" not in pair:
98
+ raise click.BadParameter(
99
+ f"{param.name} must be a valid JSON string or key=value pairs."
100
+ )
101
+ key, val = pair.split("=", 1)
102
+ result[key.strip()] = val.strip()
103
+ return result
104
+
105
+ if "{" not in value and "}" not in value:
106
+ # Treat it as a primitive if it doesn't look like JSON.
107
+ try:
108
+ value = int(value)
109
+ except ValueError:
110
+ with contextlib.suppress(ValueError):
111
+ value = float(value)
112
+ return value
113
+
10
114
  try:
11
115
  return json.loads(value)
12
116
  except json.JSONDecodeError as err:
@@ -26,6 +130,29 @@ def set_if_not_default(ctx: click.Context, **kwargs) -> dict[str, Any]:
26
130
  return values
27
131
 
28
132
 
133
+ def format_list_arg(
134
+ value: Any, default: Any = None, simplify_single: bool = False
135
+ ) -> list[Any] | Any:
136
+ """
137
+ Format a multi-argument value for display.
138
+
139
+ :param value: The value to format, which can be a single value or a list/tuple.
140
+ :param default: The default value to set if the value is non truthy.
141
+ :param simplify_single: If True and the value is a single-item list/tuple,
142
+ return the single item instead of a list.
143
+ :return: Formatted list of values, or single value if simplify_single and applicable
144
+ """
145
+ if not value:
146
+ return default
147
+
148
+ if isinstance(value, tuple):
149
+ value = list(value)
150
+ elif not isinstance(value, list):
151
+ value = [value]
152
+
153
+ return value if not simplify_single or len(value) != 1 else value[0]
154
+
155
+
29
156
  class Union(click.ParamType):
30
157
  """
31
158
  A custom click parameter type that allows for multiple types to be accepted.
@@ -35,7 +162,7 @@ class Union(click.ParamType):
35
162
  self.types = types
36
163
  self.name = "".join(t.name for t in types)
37
164
 
38
- def convert(self, value, param, ctx): # noqa: RET503
165
+ def convert(self, value, param, ctx):
39
166
  fails = []
40
167
  for t in self.types:
41
168
  try:
@@ -46,9 +173,9 @@ class Union(click.ParamType):
46
173
 
47
174
  self.fail("; ".join(fails) or f"Invalid value: {value}") # noqa: RET503
48
175
 
49
- def get_metavar(self, param: click.Parameter) -> str:
176
+ def get_metavar(self, param: click.Parameter, ctx: click.Context) -> str:
50
177
  def get_choices(t: click.ParamType) -> str:
51
- meta = t.get_metavar(param)
178
+ meta = t.get_metavar(param, ctx)
52
179
  return meta if meta is not None else t.name
53
180
 
54
181
  # Get the choices for each type in the union.