Files
sglang/python/sglang/srt/arg_groups/fields/__init__.py
T
Cheng Wan ed82def55f [Config] Round 6.2: the field declarations move to their namespaces, and the record is assembled from them (#38047)
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.
2026-09-06 21:40:31 -07:00

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},
)