Second of four; stacked on #38046. Mechanical relocation plus one design change that the relocation makes possible. **Review by checking the identity proofs at the bottom** -- nothing here is meant to change behaviour. ## The declarations move `ServerArgs` carried all 487 declarations in one 4,462-line file, each tagged with an `NS("...")` marker naming the namespace it belongs to -- structure supplied by annotation, in a file a namespace away from the `arg_groups/*_hook.py` that resolves it. They move to `arg_groups/fields/`: one module per top-level namespace, one class per leaf namespace (21 of them, `exec.moe` becomes `exec_.py::ExecMoe`). The class carries the `_NS_PATH` it stands for, so the module a field is declared in *is* its namespace and the marker is redundant -- `namespace_of` reads the declaring class instead. `NS` stays for the one case a class cannot express: a single ad-hoc dataclass whose fields span namespaces, which is what the config-bag tests build. Two things travel with the declarations. The `*_CHOICES` lists and the `add_*_choices` adders that extend them move to `arg_groups/choices.py`, since the fields naming them can no longer import from `server_args` without a cycle; `server_args` re-exports all of them, because out-of-tree plugins have always reached them there. And five fields whose only annotation element was the namespace marker become plain annotations -- `A` is `Annotated`, which needs two arguments, so stripping the marker would have left them invalid. `server_args.py` goes from 4,458 lines to about 1,000. ## The record is assembled, not inherited Inheriting the namespace classes would make the record's contents a property of which classes happen to appear in a base list. That is correct only while every namespace declares nothing but operator input, and it stops being correct the moment a derived field is declared: `attn_tp_size` belongs in `parallel.py` next to the leaves it is derived from, and inheriting `Parallel` would put it on the record -- where it is neither input nor safe, since the record is what crosses a process boundary and a derived width pickled to a subprocess is a stamp that elastic scale-up will not refresh. `collect_input_fields` takes the classes that declare input and returns their annotations, defaults and namespaces. Each source's annotations are resolved in its own module and handed on as type objects; carried across as text they would be re-evaluated where they land, and the composing module deliberately imports none of the names the declarations use. A namespace can now declare both halves side by side, and which half reaches the record is one readable call rather than an invariant spread across a base-class list. Nothing is registered on the derived side yet -- this is what makes it possible. `ServerArgs` is still one flat dataclass with 494 attributes, so `server_args.tp_size`, `ServerArgs(model_path=..., tp_size=8)`, pickling to a subprocess and every existing call site are untouched. ### Field order is a contract, so it is written down A dataclass turns field order into a positional constructor signature, and collecting whole namespaces groups fields that used to be interleaved. Keeping `model_path` first is not enough: `ServerArgs("dummy", "/tmp/tokenizer")` would set `load_format="/tmp/tokenizer"` and leave `tokenizer_path=None`, which then selects an invalid model loader -- silently, at a call site that did not change. So `arg_groups/field_order.py` records the order the record had before the split, and `collect_input_fields` orders what it collects by it. A field the record declares that the frozen order does not name goes after it, in declaration order -- the only backward-compatible place for a new field anyway, so a new declaration needs no edit there. The list is a compatibility record and nothing else reads it; the namespace a field belongs to is still the module it is declared in. ## Verification Four ways, all against the base commit: | check | result | |---|---| | `namespace_of` map, field by field | 494 / 494, **0 differences** | | CLI surface (options, defaults, choices, actions) | 507 / 507, **0 differences** | | field order, name by name | 494 / 494, **identical to the base** | | resolution result, 24 launch shapes x 489 fields | **0 differences** | | names importable from `sglang.srt.server_args` | nothing lost | Plus a full registered-unit sweep (648 files) against the stack's merge-base: 19 failures on both sides, the same 19, none of them config.
71 lines
3.3 KiB
Python
71 lines
3.3 KiB
Python
"""Config field declarations, one module per top-level namespace.
|
|
|
|
Each class carries the ``_NS_PATH`` it stands for, so the module a field is
|
|
declared in *is* its namespace. ``ServerArgs`` is assembled from the classes
|
|
that declare **operator input**; the derived fields, which nobody can type,
|
|
are declared beside them but are not collected into the record.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from typing import Any, Dict, List, Tuple, get_type_hints
|
|
|
|
from sglang.srt.arg_groups.field_order import POSITIONAL_FIELD_ORDER
|
|
|
|
|
|
def collect_input_fields(
|
|
sources: List[type],
|
|
) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, str]]:
|
|
"""The annotations and defaults of every field these classes declare.
|
|
|
|
Each source's annotations are resolved **in its own module** and handed on
|
|
as type objects. Strings never travel: an annotation carried across as text
|
|
would be re-evaluated where it lands, and the composing module does not
|
|
import the names the declarations use -- that is the whole point of them
|
|
living where they do.
|
|
|
|
Returns the resolved annotations, the defaults, and ``{field: namespace}``
|
|
-- the last because the assembled record has no base classes to read a
|
|
``_NS_PATH`` off, and the namespace is still the class that declared it.
|
|
|
|
Which classes are passed here is the record's contract: the record holds
|
|
what the operator asked for, so a class of derived fields is simply not in
|
|
the list, and the rule is one readable call rather than an invariant spread
|
|
across a base-class list.
|
|
|
|
The result is ordered by ``POSITIONAL_FIELD_ORDER``, not by namespace: a
|
|
dataclass turns field order into a positional constructor signature, and
|
|
grouping fields that used to be interleaved would silently move
|
|
``ServerArgs(model_path, tokenizer_path)``'s second argument onto another
|
|
field. Anything the record declares that the frozen order does not name
|
|
goes after it, in declaration order -- the only backward-compatible place
|
|
for a new field.
|
|
"""
|
|
annotations: Dict[str, Any] = {}
|
|
defaults: Dict[str, Any] = {}
|
|
for source in sources:
|
|
hints = get_type_hints(source, include_extras=True)
|
|
for field in dataclasses.fields(source):
|
|
if field.name in annotations:
|
|
raise ValueError(
|
|
f"{field.name!r} is declared by both "
|
|
f"{annotations[field.name][0].__name__} and {source.__name__}; "
|
|
"a field belongs to exactly one namespace"
|
|
)
|
|
annotations[field.name] = (source, hints[field.name])
|
|
if field.default is not dataclasses.MISSING:
|
|
defaults[field.name] = field.default
|
|
elif field.default_factory is not dataclasses.MISSING:
|
|
defaults[field.name] = dataclasses.field(
|
|
default_factory=field.default_factory
|
|
)
|
|
known = [n for n in POSITIONAL_FIELD_ORDER if n in annotations]
|
|
rest = [n for n in annotations if n not in set(POSITIONAL_FIELD_ORDER)]
|
|
ordered = known + rest
|
|
return (
|
|
{name: annotations[name][1] for name in ordered},
|
|
{name: defaults[name] for name in ordered if name in defaults},
|
|
{name: annotations[name][0]._NS_PATH for name in ordered},
|
|
)
|