Skip to content

Commit c56ec04

Browse files
committed
Refactor: extract basic catalog logic into its own package
1 parent 8774f3f commit c56ec04

19 files changed

Lines changed: 327 additions & 184 deletions

File tree

‎a2a_agents/python/a2ui_agent/pack_specs_hook.py‎

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,41 +14,58 @@
1414

1515
import importlib.util
1616
import os
17+
import sys
1718
import shutil
1819
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
1920

2021

21-
def load_constants(project_root):
22-
"""Loads the shared constants module directly from its path in src/."""
23-
constants_path = os.path.join(
24-
project_root, "src", "a2ui", "inference", "schema", "constants.py"
25-
)
26-
if not os.path.exists(constants_path):
27-
raise RuntimeError(f"Could not find shared constants at {constants_path}")
22+
def load_module(project_root, rel_path, filename, module_name):
23+
"""Loads a module directly from its path in src/."""
24+
path = os.path.join(project_root, "src", rel_path.replace(".", os.sep), filename)
25+
if not os.path.exists(path):
26+
raise RuntimeError(f"Could not find module at {path}")
2827

29-
spec = importlib.util.spec_from_file_location("_constants_load", constants_path)
28+
# Add src to sys.path so absolute imports work
29+
src_path = os.path.abspath(os.path.join(project_root, "src"))
30+
if src_path not in sys.path:
31+
sys.path.insert(0, src_path)
32+
33+
spec = importlib.util.spec_from_file_location(module_name, path)
3034
if spec and spec.loader:
3135
module = importlib.util.module_from_spec(spec)
36+
# Set the package context to allow relative imports if any
37+
module.__package__ = rel_path
38+
sys.modules[module_name] = module
3239
spec.loader.exec_module(module)
3340
return module
34-
raise RuntimeError(f"Could not load shared constants from {constants_path}")
41+
raise RuntimeError(f"Could not load module from {path}")
3542

3643

3744
class PackSpecsBuildHook(BuildHookInterface):
3845

3946
def initialize(self, version, build_data):
4047
project_root = self.root
4148

42-
# Load constants dynamically from src/a2ui/inference/schema/constants.py
43-
a2ui_constants = load_constants(project_root)
49+
# Load constants and utils dynamically from src/
50+
schema_path = "a2ui.inference.schema"
51+
a2ui_constants = load_module(
52+
project_root, schema_path, "constants.py", "_constants_load"
53+
)
54+
a2ui_utils = load_module(project_root, schema_path, "utils.py", "_utils_load")
55+
56+
basic_catalog_constants = load_module(
57+
project_root,
58+
"a2ui.inference.basic_catalog",
59+
"constants.py",
60+
"_basic_catalog_constants_load",
61+
)
4462

4563
spec_version_map = a2ui_constants.SPEC_VERSION_MAP
4664
a2ui_asset_package = a2ui_constants.A2UI_ASSET_PACKAGE
4765
specification_dir = a2ui_constants.SPECIFICATION_DIR
4866

49-
# project root is in a2a_agents/python/a2ui_agent
5067
# Dynamically find repo root by looking for specification_dir
51-
repo_root = a2ui_constants.find_repo_root(project_root)
68+
repo_root = a2ui_utils.find_repo_root(project_root)
5269
if not repo_root:
5370
# Check for PKG-INFO which implies a packaged state (sdist).
5471
# If PKG-INFO is present, trust the bundled assets.
@@ -66,23 +83,40 @@ def initialize(self, version, build_data):
6683
project_root, "src", a2ui_asset_package.replace(".", os.sep)
6784
)
6885

69-
for ver, schema_map in spec_version_map.items():
86+
self._pack_schemas(repo_root, spec_version_map, target_base)
87+
self._pack_basic_catalogs(
88+
repo_root, basic_catalog_constants.BASIC_CATALOG_PATHS, target_base
89+
)
90+
91+
def _pack_schemas(self, repo_root, spec_map, target_base):
92+
for ver, schema_map in spec_map.items():
7093
target_dir = os.path.join(target_base, ver)
7194
os.makedirs(target_dir, exist_ok=True)
7295

7396
for _schema_key, source_rel_path in schema_map.items():
74-
source_path = os.path.join(repo_root, source_rel_path)
97+
self._copy_schema(repo_root, source_rel_path, target_dir)
7598

76-
if not os.path.exists(source_path):
77-
print(
78-
f"WARNING: Source schema file not found at {source_path}. Build"
79-
" might produce incomplete wheel if not running from monorepo"
80-
" root."
81-
)
82-
continue
99+
def _pack_basic_catalogs(self, repo_root, catalog_paths, target_base):
100+
for ver, path_map in catalog_paths.items():
101+
target_dir = os.path.join(target_base, ver)
102+
os.makedirs(target_dir, exist_ok=True)
103+
104+
for _key, source_rel_path in path_map.items():
105+
self._copy_schema(repo_root, source_rel_path, target_dir)
106+
107+
def _copy_schema(self, repo_root, source_rel_path, target_dir):
108+
source_path = os.path.join(repo_root, source_rel_path)
109+
110+
if not os.path.exists(source_path):
111+
print(
112+
f"WARNING: Source schema file not found at {source_path}. Build"
113+
" might produce incomplete wheel if not running from monorepo"
114+
" root."
115+
)
116+
return
83117

84-
filename = os.path.basename(source_path)
85-
dst_file = os.path.join(target_dir, filename)
118+
filename = os.path.basename(source_path)
119+
dst_file = os.path.join(target_dir, filename)
86120

87-
print(f"Copying {source_path} -> {dst_file}")
88-
shutil.copy2(source_path, dst_file)
121+
print(f"Copying {source_path} -> {dst_file}")
122+
shutil.copy2(source_path, dst_file)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .provider import BasicCatalog
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from ..schema.constants import CATALOG_SCHEMA_KEY, VERSION_0_8, VERSION_0_9
16+
17+
BASIC_CATALOG_NAME = "basic"
18+
19+
# Maps version to the relative path of the basic catalog schema in the source repo
20+
BASIC_CATALOG_PATHS = {
21+
VERSION_0_8: {
22+
CATALOG_SCHEMA_KEY: "specification/v0_8/json/standard_catalog_definition.json"
23+
},
24+
VERSION_0_9: {CATALOG_SCHEMA_KEY: "specification/v0_9/json/basic_catalog.json"},
25+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Any, Dict, Optional
16+
17+
from ..schema.catalog import CatalogConfig
18+
from ..schema.catalog_provider import A2uiCatalogProvider
19+
from ..schema.utils import load_from_bundled_resource
20+
from ..schema.constants import BASE_SCHEMA_URL, CATALOG_ID_KEY, CATALOG_SCHEMA_KEY
21+
from .constants import BASIC_CATALOG_NAME, BASIC_CATALOG_PATHS
22+
23+
24+
class BundledCatalogProvider(A2uiCatalogProvider):
25+
"""Loads schemas from bundled package resources with fallbacks."""
26+
27+
def __init__(self, version: str):
28+
self.version = version
29+
30+
def load(self) -> Dict[str, Any]:
31+
# Use load_from_bundled_resource but with the specialized basic catalog paths
32+
resource = load_from_bundled_resource(
33+
self.version, CATALOG_SCHEMA_KEY, BASIC_CATALOG_PATHS
34+
)
35+
36+
# Post-load processing for catalogs
37+
if CATALOG_ID_KEY not in resource:
38+
spec_map = BASIC_CATALOG_PATHS.get(self.version)
39+
if spec_map and CATALOG_SCHEMA_KEY in spec_map:
40+
rel_path = spec_map[CATALOG_SCHEMA_KEY]
41+
# Strip the `json/` part from the catalog file path for the ID.
42+
catalog_file = rel_path.replace("/json/", "/")
43+
resource[CATALOG_ID_KEY] = BASE_SCHEMA_URL + catalog_file
44+
45+
if "$schema" not in resource:
46+
resource["$schema"] = "https://json-schema.org/draft/2020-12/schema"
47+
48+
return resource
49+
50+
51+
class BasicCatalog:
52+
"""Helper for accessing the basic A2UI catalog."""
53+
54+
@staticmethod
55+
def get_config(version: str, examples_path: Optional[str] = None) -> CatalogConfig:
56+
"""Returns a CatalogConfig for the basic bundled catalog."""
57+
return CatalogConfig(
58+
name=BASIC_CATALOG_NAME,
59+
provider=BundledCatalogProvider(version),
60+
examples_path=examples_path,
61+
)

‎a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
from dataclasses import dataclass, field, replace
2020
from typing import Any, Dict, List, Optional, TYPE_CHECKING
2121

22-
from .catalog_provider import A2uiCatalogProvider, FileSystemCatalogProvider, BundledCatalogProvider
23-
from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY, BASIC_CATALOG_NAME
22+
from .catalog_provider import A2uiCatalogProvider, FileSystemCatalogProvider
23+
from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY
2424

2525

2626
@dataclass
@@ -41,17 +41,6 @@ class CatalogConfig:
4141
provider: A2uiCatalogProvider
4242
examples_path: Optional[str] = None
4343

44-
@classmethod
45-
def bundled(
46-
cls, version: str, examples_path: Optional[str] = None
47-
) -> "CatalogConfig":
48-
"""Returns a CatalogConfig for the basic bundled catalog."""
49-
return cls(
50-
name=BASIC_CATALOG_NAME,
51-
provider=BundledCatalogProvider(version),
52-
examples_path=examples_path,
53-
)
54-
5544
@classmethod
5645
def from_path(
5746
cls, name: str, catalog_path: str, examples_path: Optional[str] = None

‎a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog_provider.py‎

Lines changed: 2 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,10 @@
1515
"""Module for providing A2UI catalog schemas and resources."""
1616

1717
import json
18-
import logging
19-
import os
20-
import importlib.resources
2118
from abc import ABC, abstractmethod
2219
from json.decoder import JSONDecodeError
23-
from typing import Any, Dict, Optional
24-
25-
from .constants import (
26-
A2UI_ASSET_PACKAGE,
27-
BASE_SCHEMA_URL,
28-
CATALOG_ID_KEY,
29-
CATALOG_SCHEMA_KEY,
30-
SPEC_VERSION_MAP,
31-
find_repo_root,
32-
)
33-
34-
ENCODING = "utf-8"
20+
from typing import Any, Dict
21+
from .constants import ENCODING
3522

3623

3724
class A2uiCatalogProvider(ABC):
@@ -59,84 +46,3 @@ def load(self) -> Dict[str, Any]:
5946
return json.load(f)
6047
except (FileNotFoundError, JSONDecodeError) as e:
6148
raise IOError(f"Could not load schema from {self.path}: {e}") from e
62-
63-
64-
def load_from_bundled_resource(version: str, resource_key: str) -> Dict[str, Any]:
65-
"""Loads a schema resource from bundled package resources."""
66-
spec_map = SPEC_VERSION_MAP.get(version)
67-
if not spec_map:
68-
raise ValueError(f"Unknown A2UI version: {version}")
69-
70-
if resource_key not in spec_map:
71-
return None
72-
73-
rel_path = spec_map[resource_key]
74-
filename = os.path.basename(rel_path)
75-
76-
# 1. Try to load from the bundled package resources.
77-
try:
78-
traversable = importlib.resources.files(A2UI_ASSET_PACKAGE)
79-
traversable = traversable.joinpath(version).joinpath(filename)
80-
with traversable.open("r", encoding=ENCODING) as f:
81-
return json.load(f)
82-
except Exception as e:
83-
logging.debug("Could not load '%s' from package resources: %s", filename, e)
84-
85-
# 2. Fallback to local assets
86-
# This handles cases where assets might be present in src but not installed
87-
try:
88-
potential_path = os.path.abspath(
89-
os.path.join(
90-
os.path.dirname(__file__),
91-
"..",
92-
"assets",
93-
version,
94-
filename,
95-
)
96-
)
97-
if os.path.exists(potential_path):
98-
provider = FileSystemCatalogProvider(potential_path)
99-
return provider.load()
100-
except Exception as e:
101-
logging.debug("Could not load schema '%s' from local assets: %s", filename, e)
102-
103-
# 3. Fallback: Source Repository (specification/...)
104-
# This handles cases where we are running directly from source tree
105-
# And assets are not yet copied to src/a2ui/assets
106-
# manager.py is at a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py
107-
# Dynamically find repo root by looking for "specification" directory
108-
try:
109-
repo_root = find_repo_root(os.path.dirname(__file__))
110-
if repo_root:
111-
source_path = os.path.join(repo_root, rel_path)
112-
if os.path.exists(source_path):
113-
provider = FileSystemCatalogProvider(source_path)
114-
return provider.load()
115-
except Exception as e:
116-
logging.debug("Could not load schema from source repo: %s", e)
117-
118-
raise IOError(f"Could not load schema {filename} for version {version}")
119-
120-
121-
class BundledCatalogProvider(A2uiCatalogProvider):
122-
"""Loads schemas from bundled package resources with fallbacks."""
123-
124-
def __init__(self, version: str):
125-
self.version = version
126-
127-
def load(self) -> Dict[str, Any]:
128-
129-
resource = load_from_bundled_resource(self.version, CATALOG_SCHEMA_KEY)
130-
131-
# Post-load processing for catalogs
132-
if CATALOG_ID_KEY not in resource:
133-
spec_map = SPEC_VERSION_MAP.get(self.version)
134-
rel_path = spec_map[CATALOG_SCHEMA_KEY]
135-
# Strip the `json/` part from the catalog file path for the ID.
136-
catalog_file = rel_path.replace("/json/", "/")
137-
resource[CATALOG_ID_KEY] = BASE_SCHEMA_URL + catalog_file
138-
139-
if "$schema" not in resource:
140-
resource["$schema"] = "https://json-schema.org/draft/2020-12/schema"
141-
142-
return resource

0 commit comments

Comments
 (0)