Skip to content

Commit 4d4152a

Browse files
committed
feat(rails): add versioned rail manifest contract
1 parent 62d119d commit 4d4152a

6 files changed

Lines changed: 951 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Public API for the rail manifest contract, catalog, and config schema.
17+
18+
Re-exports the manifest types (`RailManifest`, `RailMetadata`,
19+
`RailSpec`, and friends) and the `RailCatalog`, and provides
20+
process-wide accessors for the default catalog of built-in rails
21+
(`default_rail_catalog`, `rail_catalog`, `rail_surfaces`).
22+
"""
23+
24+
from nemoguardrails.manifests.catalog import RailCatalog, RailManifestRecord
25+
from nemoguardrails.manifests.manifest import (
26+
ActionRef,
27+
Binding,
28+
ConfigSpecRef,
29+
EnvVar,
30+
ExampleRef,
31+
ImportRef,
32+
ModelRequirement,
33+
RailActions,
34+
RailCapability,
35+
RailCategory,
36+
RailConfigSchema,
37+
RailDirection,
38+
RailFlows,
39+
RailLifecycle,
40+
RailManifest,
41+
RailMetadata,
42+
RailPrivacy,
43+
RailRequirements,
44+
RailSpec,
45+
RailStatus,
46+
RailSurface,
47+
ServiceRequirement,
48+
TransformTarget,
49+
import_ref_target,
50+
iter_manifest_import_refs,
51+
iter_manifest_import_targets,
52+
normalize_configured_surface_name,
53+
parse_configured_surface,
54+
resolve_import_ref,
55+
)
56+
from nemoguardrails.manifests.manifest import (
57+
configured_rail_surfaces as _configured_rail_surfaces,
58+
)
59+
60+
_catalog: RailCatalog | None = None
61+
_discovering = False
62+
_plugin_catalogs = {}
63+
64+
65+
def default_rail_catalog() -> RailCatalog:
66+
global _catalog, _discovering
67+
if _catalog is not None:
68+
return _catalog
69+
if _discovering:
70+
raise RuntimeError("Built-in rail manifest discovery re-entered while loading rail modules.")
71+
_discovering = True
72+
try:
73+
catalog = RailCatalog.discover_built_ins()
74+
_catalog = catalog
75+
return catalog
76+
finally:
77+
_discovering = False
78+
79+
80+
def all_rail_manifests():
81+
return dict(default_rail_catalog().manifests)
82+
83+
84+
def rail_catalog(enabled_plugins=()):
85+
names = tuple(sorted(set(enabled_plugins)))
86+
if not names:
87+
return default_rail_catalog()
88+
catalog = _plugin_catalogs.get(names)
89+
if catalog is None:
90+
catalog = default_rail_catalog().with_plugins(names)
91+
_plugin_catalogs[names] = catalog
92+
return catalog
93+
94+
95+
def rail_surfaces(direction: RailDirection | str | None = None):
96+
parsed_direction = RailDirection(direction) if direction is not None else None
97+
return default_rail_catalog().surfaces(parsed_direction)
98+
99+
100+
def surface_names(direction: RailDirection | str):
101+
parsed_direction = RailDirection(direction)
102+
return tuple(sorted(name for _direction, name in rail_surfaces(parsed_direction)))
103+
104+
105+
def configured_rail_surfaces(direction: RailDirection | str, flows):
106+
parsed_direction = RailDirection(direction)
107+
return _configured_rail_surfaces(parsed_direction, flows, rail_surfaces(parsed_direction))
108+
109+
110+
selected_rail_surfaces = configured_rail_surfaces
111+
112+
113+
def _reset_rail_manifest_cache() -> None:
114+
global _catalog, _discovering
115+
_catalog = None
116+
_discovering = False
117+
_plugin_catalogs.clear()
118+
119+
120+
__all__ = [
121+
"ActionRef",
122+
"Binding",
123+
"ConfigSpecRef",
124+
"EnvVar",
125+
"ExampleRef",
126+
"ImportRef",
127+
"ModelRequirement",
128+
"RailActions",
129+
"RailCapability",
130+
"RailCatalog",
131+
"RailCategory",
132+
"RailConfigSchema",
133+
"RailDirection",
134+
"RailFlows",
135+
"RailLifecycle",
136+
"RailManifest",
137+
"RailManifestRecord",
138+
"RailMetadata",
139+
"RailPrivacy",
140+
"RailRequirements",
141+
"RailSpec",
142+
"RailStatus",
143+
"RailSurface",
144+
"ServiceRequirement",
145+
"TransformTarget",
146+
"all_rail_manifests",
147+
"configured_rail_surfaces",
148+
"default_rail_catalog",
149+
"import_ref_target",
150+
"iter_manifest_import_refs",
151+
"iter_manifest_import_targets",
152+
"normalize_configured_surface_name",
153+
"parse_configured_surface",
154+
"rail_catalog",
155+
"rail_surfaces",
156+
"resolve_import_ref",
157+
"selected_rail_surfaces",
158+
"surface_names",
159+
]
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""In-memory registry of rail manifests and the surfaces they expose.
17+
18+
Collects `RailManifestRecord` entries (built-in rails discovered under
19+
`nemoguardrails/library` plus enabled plugin entry points) into an immutable
20+
`RailCatalog` that enforces global uniqueness of rail names, config keys,
21+
action names, and surface keys.
22+
"""
23+
24+
import importlib
25+
import importlib.metadata
26+
from dataclasses import dataclass
27+
from pathlib import Path
28+
from typing import Dict, Iterable, Mapping, Optional, Tuple
29+
30+
from nemoguardrails.manifests.manifest import ActionRef, RailDirection, RailManifest, RailSurface
31+
32+
33+
@dataclass(frozen=True, slots=True)
34+
class RailManifestRecord:
35+
manifest: RailManifest
36+
source: str
37+
distribution: Optional[str] = None
38+
built_in: bool = True
39+
40+
41+
class RailCatalog:
42+
"""Immutable index of rail manifests keyed by name and surface.
43+
44+
Constructing a catalog validates the combined set of records and raises
45+
`ValueError` on any collision: duplicate manifest names, duplicate config
46+
keys, duplicate action names, or two rails claiming the same
47+
`(direction, surface name)`. It also rejects a surface whose action is not
48+
declared in that surface's own manifest.
49+
"""
50+
51+
def __init__(self, records: Iterable[RailManifestRecord] = ()) -> None:
52+
records_by_name: Dict[str, RailManifestRecord] = {}
53+
surfaces: Dict[Tuple[RailDirection, str], RailSurface] = {}
54+
surface_owners: Dict[Tuple[RailDirection, str], str] = {}
55+
config_owners: Dict[str, str] = {}
56+
action_owners: Dict[str, Tuple[str, ActionRef]] = {}
57+
for record in records:
58+
manifest = record.manifest
59+
declared_actions = set(manifest.actions.refs if manifest.actions is not None else ())
60+
existing = records_by_name.get(manifest.name)
61+
if existing is not None:
62+
raise ValueError(
63+
f"Rail manifest {manifest.name!r} is already provided by {existing.source!r}; "
64+
f"cannot also provide it from {record.source!r}."
65+
)
66+
if manifest.config_schema is not None:
67+
key = manifest.config_schema.key
68+
owner = config_owners.get(key)
69+
if owner is not None:
70+
raise ValueError(
71+
f"Rail config key {key!r} is already provided by {owner!r}; "
72+
f"cannot also provide it from {manifest.name!r}."
73+
)
74+
config_owners[key] = manifest.name
75+
if manifest.actions is not None:
76+
for action_ref in manifest.actions.refs:
77+
existing_action = action_owners.get(action_ref.name)
78+
if existing_action is not None:
79+
raise ValueError(
80+
f"Rail action {action_ref.name!r} is already provided by {existing_action[0]!r}; "
81+
f"cannot also provide it from {manifest.name!r}."
82+
)
83+
action_owners[action_ref.name] = (manifest.name, action_ref)
84+
for surface in manifest.surfaces:
85+
if surface.action not in declared_actions:
86+
raise ValueError(
87+
f"Rail surface {surface.name!r} from {manifest.name!r} references action "
88+
f"{surface.action.name!r}, which is not declared in that manifest."
89+
)
90+
key = (surface.direction, surface.name)
91+
owner = surface_owners.get(key)
92+
if owner is not None:
93+
raise ValueError(
94+
f"Rail surface {surface.name!r} for direction {surface.direction.value!r} is already "
95+
f"provided by {owner!r}; cannot also provide it from {manifest.name!r}."
96+
)
97+
surfaces[key] = surface
98+
surface_owners[key] = manifest.name
99+
records_by_name[manifest.name] = record
100+
self._records = records_by_name
101+
self._surfaces = surfaces
102+
103+
@classmethod
104+
def discover_built_ins(cls, library_path: Optional[Path] = None) -> "RailCatalog":
105+
if library_path is None:
106+
library_path = Path(__file__).resolve().parents[1] / "library"
107+
records = []
108+
for manifest_file in sorted(library_path.rglob("rail.py")):
109+
relative_module = manifest_file.relative_to(library_path).with_suffix("")
110+
module_name = ".".join(("nemoguardrails", "library", *relative_module.parts))
111+
module = importlib.import_module(module_name)
112+
manifest = getattr(module, "RAIL", None)
113+
if not isinstance(manifest, RailManifest):
114+
raise TypeError(f"Rail manifest module {module_name!r} must define RAIL as a RailManifest.")
115+
manifest = manifest.model_copy(update={"origin": module_name})
116+
records.append(RailManifestRecord(manifest=manifest, source=module_name))
117+
return cls(records)
118+
119+
def with_plugins(self, enabled: Iterable[str]) -> "RailCatalog":
120+
enabled_names = tuple(dict.fromkeys(enabled))
121+
if not enabled_names:
122+
return self
123+
discovered = importlib.metadata.entry_points()
124+
candidates = list(discovered.select(group="nemoguardrails.rails"))
125+
by_name: Dict[str, list] = {}
126+
for candidate in candidates:
127+
by_name.setdefault(candidate.name, []).append(candidate)
128+
records = list(self._records.values())
129+
for name in enabled_names:
130+
matches = by_name.get(name, [])
131+
if not matches:
132+
raise ValueError(f"Enabled rail plugin {name!r} is not installed.")
133+
if len(matches) != 1:
134+
distributions = sorted(
135+
(candidate.dist.name if candidate.dist is not None else "unknown") for candidate in matches
136+
)
137+
raise ValueError(f"Rail plugin {name!r} is provided by multiple distributions: {distributions}.")
138+
candidate = matches[0]
139+
manifest = candidate.load()
140+
if callable(manifest) and not isinstance(manifest, RailManifest):
141+
manifest = manifest()
142+
if not isinstance(manifest, RailManifest):
143+
raise TypeError(f"Rail plugin entry point {name!r} must resolve to a RailManifest.")
144+
if manifest.name != name:
145+
raise ValueError(f"Rail plugin entry point {name!r} returned manifest {manifest.name!r}.")
146+
distribution = candidate.dist.name if candidate.dist is not None else None
147+
records.append(
148+
RailManifestRecord(
149+
manifest=manifest.model_copy(update={"origin": candidate.value}),
150+
source=candidate.value,
151+
distribution=distribution,
152+
built_in=False,
153+
)
154+
)
155+
return type(self)(records)
156+
157+
@property
158+
def records(self) -> Mapping[str, RailManifestRecord]:
159+
return dict(self._records)
160+
161+
@property
162+
def manifests(self) -> Mapping[str, RailManifest]:
163+
return {name: record.manifest for name, record in self._records.items()}
164+
165+
def surfaces(self, direction: Optional[RailDirection] = None) -> Dict:
166+
return {key: surface for key, surface in self._surfaces.items() if direction is None or key[0] == direction}

0 commit comments

Comments
 (0)