Skip to content

Commit 124c59e

Browse files
Updated schema generation script to improve type handling and user-friendly type display
1 parent fb4a4da commit 124c59e

4 files changed

Lines changed: 166 additions & 41 deletions

File tree

docs/docs/reference/dstack.yml/service.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ The `service` configuration type allows running [services](../../concepts/servic
6363
1. Doesn't work if your `chat_template` uses `bos_token`. As a workaround, replace `bos_token` inside `chat_template` with the token content itself.
6464
2. Doesn't work if `eos_token` is defined in the model repository as a dictionary. As a workaround, set `eos_token` manually, as shown in the example above (see Chat template).
6565

66-
If you encounter any other issues, please make sure to file a
66+
If you encounter any ofther issues, please make sure to file a
6767
[GitHub issue](https://github.com/dstackai/dstack/issues/new/choose).
6868

6969
### `scaling`
@@ -127,6 +127,16 @@ The `service` configuration type allows running [services](../../concepts/servic
127127
required: true
128128

129129

130+
### `replicas`
131+
132+
#### `replicas[n]`
133+
134+
#SCHEMA# dstack._internal.core.models.configurations.ReplicaGroup
135+
overrides:
136+
show_root_heading: false
137+
type:
138+
required: true
139+
130140
### `retry`
131141

132142
#SCHEMA# dstack._internal.core.models.profiles.ProfileRetry

docs/docs/reference/server/config.yml.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ to configure [backends](../../concepts/backends.md) and other [server-level sett
1414
#SCHEMA# dstack._internal.server.services.config.ProjectConfig
1515
overrides:
1616
show_root_heading: false
17-
backends:
18-
type: 'Union[AWSBackendConfigWithCreds, AzureBackendConfigWithCreds, GCPBackendConfigWithCreds, HotAisleBackendConfigWithCreds, LambdaBackendConfigWithCreds, NebiusBackendConfigWithCreds, RunpodBackendConfigWithCreds, VastAIBackendConfigWithCreds, KubernetesConfig]'
1917

2018
#### `projects[n].backends` { #backends data-toc-label="backends" }
2119

scripts/docs/gen_schema_reference.py

Lines changed: 151 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,144 @@
2323
logger.info("Generating schema reference...")
2424

2525

26-
def get_type(annotation: Type) -> str:
26+
def _is_linkable_type(annotation: Any) -> bool:
27+
"""Check if a type annotation contains a BaseModel subclass (excluding Range)."""
28+
if inspect.isclass(annotation):
29+
return issubclass(annotation, BaseModel) and not issubclass(annotation, Range)
30+
origin = get_origin(annotation)
31+
if origin is Annotated:
32+
return _is_linkable_type(get_args(annotation)[0])
33+
if origin is Union:
34+
return any(_is_linkable_type(arg) for arg in get_args(annotation))
35+
if origin is list:
36+
args = get_args(annotation)
37+
return bool(args) and _is_linkable_type(args[0])
38+
return False
39+
40+
41+
def _type_sort_key(t: str) -> tuple:
42+
"""Sort key for type parts: primitives first, then literals, then compound types."""
43+
order = {"bool": 0, "int": 1, "float": 2, "str": 3}
44+
if t in order:
45+
return (0, order[t])
46+
if t.startswith('"'):
47+
return (1, t)
48+
if t.startswith("list"):
49+
return (2, t)
50+
if t == "dict":
51+
return (3, "")
52+
if t == "object":
53+
return (4, "")
54+
return (5, t)
55+
56+
57+
def get_friendly_type(annotation: Type) -> str:
58+
"""Get a user-friendly type string for documentation.
59+
60+
Produces types like: ``int | str``, ``"rps"``, ``list[object]``, ``"spot" | "on-demand" | "auto"``.
61+
"""
62+
# Unwrap Annotated
2763
if get_origin(annotation) is Annotated:
28-
return get_type(get_args(annotation)[0])
64+
return get_friendly_type(get_args(annotation)[0])
65+
66+
# Handle Union (including Optional)
2967
if get_origin(annotation) is Union:
30-
# Optional is Union with None.
31-
# We don't want to show Optional[A, None] but just Optional[A]
32-
if annotation.__name__ == "Optional":
33-
args = ",".join(get_type(arg) for arg in get_args(annotation)[:-1])
34-
else:
35-
args = ",".join(get_type(arg) for arg in get_args(annotation))
36-
return f"{annotation.__name__}[{args}]"
68+
args = [a for a in get_args(annotation) if a is not type(None)]
69+
if not args:
70+
return ""
71+
parts: list = []
72+
for arg in args:
73+
friendly = get_friendly_type(arg)
74+
# Split compound types (e.g., "int | str" from Range) to deduplicate,
75+
# but avoid splitting types that contain brackets (e.g., list[...])
76+
if "[" not in friendly:
77+
for part in friendly.split(" | "):
78+
if part and part not in parts:
79+
parts.append(part)
80+
else:
81+
if friendly and friendly not in parts:
82+
parts.append(friendly)
83+
parts.sort(key=_type_sort_key)
84+
return " | ".join(parts)
85+
86+
# Handle Literal — show quoted values
3787
if get_origin(annotation) is Literal:
38-
return str(annotation).split(".", maxsplit=1)[-1]
88+
values = get_args(annotation)
89+
return " | ".join(f'"{v}"' for v in values)
90+
91+
# Handle list
3992
if get_origin(annotation) is list:
40-
return f"List[{get_type(get_args(annotation)[0])}]"
93+
args = get_args(annotation)
94+
if args:
95+
inner = get_friendly_type(args[0])
96+
# Simplify lists of enum/literal values to list[str]
97+
if inner.startswith('"') and " | " in inner:
98+
inner = "str"
99+
return f"list[{inner}]"
100+
return "list"
101+
102+
# Handle dict
41103
if get_origin(annotation) is dict:
42-
return f"Dict[{get_type(get_args(annotation)[0])}, {get_type(get_args(annotation)[1])}]"
43-
return annotation.__name__
104+
return "dict"
105+
106+
# Handle concrete classes
107+
if inspect.isclass(annotation):
108+
# Enum — show quoted values
109+
if issubclass(annotation, Enum):
110+
values = [e.value for e in annotation]
111+
return " | ".join(f'"{v}"' for v in values)
112+
113+
# Range — depends on inner type parameter
114+
if issubclass(annotation, Range):
115+
min_field = annotation.__fields__.get("min")
116+
if min_field and inspect.isclass(min_field.type_):
117+
# Range[Memory] → str, Range[int] → int | str
118+
if issubclass(min_field.type_, float):
119+
return "str"
120+
return "int | str"
121+
122+
# Memory (float subclass that parses "8GB" strings)
123+
from dstack._internal.core.models.resources import Memory as _Memory
124+
125+
if issubclass(annotation, _Memory):
126+
return "str"
127+
128+
# BaseModel subclass (not Range)
129+
if issubclass(annotation, BaseModel) and not issubclass(annotation, Range):
130+
# Root models (with __root__ field) — resolve from the root type
131+
if "__root__" in annotation.__fields__:
132+
return get_friendly_type(annotation.__fields__["__root__"].annotation)
133+
# Models with custom __get_validators__ accept primitive input (int, str)
134+
# in addition to the full object form (e.g., GPUSpec, CPUSpec, DiskSpec)
135+
if "__get_validators__" in annotation.__dict__:
136+
return "int | str | object"
137+
return "object"
138+
139+
# ComputeCapability (tuple subclass that parses "7.5" strings)
140+
if annotation.__name__ == "ComputeCapability":
141+
return "float | str"
142+
143+
# Constrained and primitive types — check MRO
144+
# bool must come before int (bool is a subclass of int)
145+
if issubclass(annotation, bool):
146+
return "bool"
147+
if issubclass(annotation, int):
148+
# Duration (int subclass that parses "5m" strings)
149+
if annotation.__name__ == "Duration":
150+
return "int | str"
151+
return "int"
152+
if issubclass(annotation, float):
153+
return "float"
154+
if issubclass(annotation, str):
155+
return "str"
156+
if issubclass(annotation, (list, tuple)):
157+
return "list"
158+
if issubclass(annotation, dict):
159+
return "dict"
160+
161+
return annotation.__name__
162+
163+
return str(annotation)
44164

45165

46166
def generate_schema_reference(
@@ -70,7 +190,7 @@ def generate_schema_reference(
70190
values = dict(
71191
name=name,
72192
description=field.field_info.description,
73-
type=get_type(field.annotation),
193+
type=get_friendly_type(field.annotation),
74194
default=default,
75195
required=field.required,
76196
)
@@ -84,11 +204,7 @@ def generate_schema_reference(
84204
if field.annotation.__name__ == "Annotated":
85205
if field_type.__name__ in ["Optional", "List", "list", "Union"]:
86206
field_type = get_args(field_type)[0]
87-
base_model = (
88-
inspect.isclass(field_type)
89-
and issubclass(field_type, BaseModel)
90-
and not issubclass(field_type, Range)
91-
)
207+
base_model = _is_linkable_type(field_type)
92208
else:
93209
base_model = False
94210
_defaults = (
@@ -114,29 +230,27 @@ def generate_schema_reference(
114230
if not base_model
115231
else f"[`{values['name']}`](#{item_id_prefix}{link_name})"
116232
)
117-
item_optional_marker = "(Optional)" if not values["required"] else ""
233+
item_required_marker = "(Required)" if values["required"] else "(Optional)"
234+
item_type_display = f"`{values['type']}`" if values.get("type") else ""
118235
item_description = (values["description"]).replace("\n", "<br>") + "."
119236
item_default = _defaults if not values["required"] else _must_be
120237
item_id = f"#{values['name']}" if not base_model else f"#_{values['name']}"
121238
item_toc_label = f"data-toc-label='{values['name']}'"
122239
item_css_cass = "class='reference-item'"
123-
rows.append(
124-
prefix
125-
+ " ".join(
126-
[
127-
f"###### {item_header}",
128-
"-",
129-
item_optional_marker,
130-
item_description,
131-
item_default,
132-
"{",
133-
item_id,
134-
item_toc_label,
135-
item_css_cass,
136-
"}",
137-
]
138-
)
139-
)
240+
parts = [
241+
f"###### {item_header}",
242+
"-",
243+
item_required_marker,
244+
item_type_display,
245+
item_description,
246+
item_default,
247+
"{",
248+
item_id,
249+
item_toc_label,
250+
item_css_cass,
251+
"}",
252+
]
253+
rows.append(prefix + " ".join(p for p in parts if p))
140254
return "\n".join(rows)
141255

142256

src/dstack/_internal/core/models/configurations.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,10 @@ def schema_extra(schema: Dict[str, Any]):
322322

323323

324324
class ProbeConfig(generate_dual_core_model(ProbeConfigConfig)):
325-
type: Literal["http"] # expect other probe types in the future, namely `exec`
325+
type: Annotated[
326+
Literal["http"],
327+
Field(description="The probe type. Must be `http`"),
328+
] # expect other probe types in the future, namely `exec`
326329
url: Annotated[
327330
Optional[str], Field(description=f"The URL to request. Defaults to `{DEFAULT_PROBE_URL}`")
328331
] = None

0 commit comments

Comments
 (0)