Skip to content

Commit c145172

Browse files
committed
use JsonSchema to validate config
1 parent 56eb539 commit c145172

File tree

2 files changed

+116
-153
lines changed

2 files changed

+116
-153
lines changed

idom/client/build_config.py

Lines changed: 115 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import ast
5+
from copy import deepcopy
56
from functools import wraps
67
from contextlib import contextmanager
78
from hashlib import sha256
@@ -13,210 +14,96 @@
1314
Dict,
1415
Optional,
1516
Any,
16-
Iterable,
1717
Iterator,
1818
TypeVar,
1919
Tuple,
2020
Callable,
21+
TypedDict,
2122
)
2223

24+
from fastjsonschema import compile as compile_schema
25+
26+
import idom
2327
from .utils import split_package_name_and_version
2428

2529

2630
_Self = TypeVar("_Self")
27-
_Class = TypeVar("_Class")
2831
_Method = TypeVar("_Method", bound=Callable[..., Any])
2932

33+
_ConfigItem = Dict[str, Any]
34+
3035

3136
def _requires_open_transaction(method: _Method) -> _Method:
3237
@wraps(method)
33-
def wrapper(self: BuildConfigFile, *args: Any, **kwargs: Any) -> Any:
38+
def wrapper(self: BuildConfig, *args: Any, **kwargs: Any) -> Any:
3439
if not self._transaction_open:
35-
raise RuntimeError("Cannot modify BuildConfigFile without transaction.")
40+
raise RuntimeError("Cannot modify BuildConfig without transaction.")
3641
return method(self, *args, **kwargs)
3742

3843
return wrapper
3944

4045

41-
class BuildConfigFile:
46+
class BuildConfig:
4247

43-
__slots__ = "_config_items", "_path", "_transaction_open"
48+
__slots__ = "config", "_path", "_transaction_open"
4449
_filename = "idom-build-config.json"
50+
_default_config = {"version": idom.__version__, "by_source": {}}
4551

4652
def __init__(self, path: Path) -> None:
4753
self._path = path / self._filename
48-
self._config_items = self._load_config_items()
54+
self.config = self._load()
55+
self._derived_properties = _derive_config_properties(self.config)
4956
self._transaction_open = False
5057

5158
@contextmanager
5259
def transaction(self: _Self) -> Iterator[_Self]:
5360
"""Open a transaction to modify the config file state"""
5461
self._transaction_open = True
55-
old_configs = self._config_items
56-
self._config_items = old_configs.copy()
62+
old_config = deepcopy(self.config)
5763
try:
5864
yield self
5965
except Exception:
60-
self._config_items = old_configs
66+
self.config = old_config
6167
raise
6268
else:
63-
self.save()
69+
self._save()
6470
finally:
6571
self._transaction_open = False
6672

67-
@property
68-
def configs(self) -> Dict[str, "BuildConfigItem"]:
69-
"""A dictionary of config items"""
70-
return self._config_items.copy()
73+
def get_js_dependency_alias(self, source_name: str, dependency_name: str) -> str:
74+
aliases_by_src = self._derived_properties["js_dependency_aliases_by_source"]
75+
return aliases_by_src[source_name][dependency_name]
7176

72-
def save(self) -> None:
73-
"""Save config state to file"""
74-
with self._path.open("w") as f:
75-
json.dump(self.to_dicts(), f)
76-
77-
def to_dicts(self) -> Dict[str, Dict[str, Any]]:
78-
"""Return string repr of config state"""
79-
return {name: conf.to_dict() for name, conf in self._config_items.items()}
80-
81-
@_requires_open_transaction
82-
def add(self, build_configs: Iterable[Any], ignore_existing: bool = False) -> None:
83-
"""Add a config item"""
84-
for config in map(to_build_config_item, build_configs):
85-
source_name = config.source_name
86-
if not ignore_existing and source_name in self._config_items:
87-
raise ValueError(f"A build config for {source_name!r} already exists")
88-
self._config_items[source_name] = config
89-
return None
90-
91-
@_requires_open_transaction
92-
def remove(self, source_name: str, ignore_missing: bool = False) -> None:
93-
"""Remove a config item"""
94-
if ignore_missing:
95-
self._config_items.pop(source_name, None)
96-
else:
97-
del self._config_items[source_name]
77+
def all_aliased_js_dependencies(self) -> List[str]:
78+
return [
79+
dep
80+
for aliased_deps in self._derived_properties[
81+
"aliased_js_dependencies_by_source"
82+
].values()
83+
for dep in aliased_deps
84+
]
9885

99-
@_requires_open_transaction
100-
def clear(self) -> None:
101-
"""Clear all config items"""
102-
self._config_items = {}
103-
104-
def _load_config_items(self) -> Dict[str, "BuildConfigItem"]:
105-
if not self._path.exists():
106-
return {}
86+
def _load(self) -> Dict[str, Any]:
10787
with self._path.open() as f:
108-
content = f.read().strip() or "{}"
109-
return {n: BuildConfigItem(**c) for n, c in json.loads(content).items()}
110-
111-
def __repr__(self) -> str:
112-
return f"{type(self).__name__}({self.to_dicts()})"
113-
114-
115-
def _save_init_params(init_method: _Method) -> _Method:
116-
@wraps(init_method)
117-
def wrapper(self: Any, **kwargs: Any) -> None:
118-
self._init_params = kwargs
119-
init_method(self, **kwargs)
120-
return None
121-
122-
return wrapper
123-
124-
125-
def to_build_config_item(value: Any) -> "BuildConfigItem":
126-
if isinstance(value, dict):
127-
return BuildConfigItem.from_dict(value)
128-
elif isinstance(value, BuildConfigItem):
129-
return value
130-
else:
131-
raise ValueError(f"Expected a BuildConfigItem or dict, not {value!r}")
132-
133-
134-
class BuildConfigItem:
135-
"""Describes build requirements for a Python package or application
136-
137-
Attributes:
138-
source_name:
139-
The name of the source where this config came from (usually a Python module)
140-
js_dependencies:
141-
A list of dependency specifiers which can be installed by NPM. The
142-
specifiers give each dependency an alias to avoid name and version
143-
clashes that might occur between configs.
144-
js_dependency_aliases:
145-
Maps the name of a dependency to the alias used in ``js_dependencies``
146-
"""
147-
148-
__slots__ = (
149-
"_init_params",
150-
"source_name",
151-
"identifier",
152-
"js_dependencies",
153-
"js_dependency_aliases",
154-
"js_dependency_alias_suffix",
155-
)
156-
157-
@_save_init_params
158-
def __init__(self, source_name: str, js_dependencies: List[str]) -> None:
159-
if not isinstance(source_name, str):
160-
raise ValueError(f"'source_name' must be a string, not {source_name!r}")
161-
if not isinstance(js_dependencies, list):
162-
raise ValueError(
163-
f"'js_dependencies' must be a list, not {js_dependencies!r}"
88+
return validate_config(
89+
json.loads(f.read() or "null") or self._default_config
16490
)
165-
for item in js_dependencies:
166-
if not isinstance(item, str):
167-
raise ValueError(
168-
f"items of 'js_dependencies' must be strings, not {item!r}"
169-
)
17091

171-
self.source_name = source_name
172-
self.js_dependencies: List[str] = []
173-
self.js_dependency_aliases: Dict[str, str] = {}
174-
self.js_dependency_alias_suffix = f"{source_name}-{format(hash(self), 'x')}"
175-
176-
for dep in js_dependencies:
177-
dep_name = split_package_name_and_version(dep)[0]
178-
dep_alias = f"{dep_name}-{self.js_dependency_alias_suffix}"
179-
self.js_dependencies.append(f"{dep_alias}@npm:{dep}")
180-
self.js_dependency_aliases[dep_name] = dep_alias
181-
182-
@classmethod
183-
def from_dict(cls: _Class, value: Any, source_name: Optional[str] = None) -> _Class:
184-
if not isinstance(value, dict):
185-
raise ValueError(f"Expected build config to be a dict, not {value!r}")
186-
if source_name is not None:
187-
value.setdefault("source_name", source_name)
188-
return cls(**value)
189-
190-
def to_dict(self) -> Dict[str, Any]:
191-
return self._init_params.copy()
192-
193-
def __eq__(self, other: Any) -> bool:
194-
return isinstance(other, type(self)) and (other.to_dict() == self.to_dict())
195-
196-
def __hash__(self) -> int:
197-
sorted_params = {k: self._init_params[k] for k in sorted(self._init_params)}
198-
param_hash = sha256(json.dumps(sorted_params).encode())
199-
return (
200-
int(param_hash.hexdigest(), 16)
201-
# chop off the last 8 digits (no need for that many)
202-
% 10 ** 8
203-
)
204-
205-
def __repr__(self) -> str:
206-
items = ", ".join(f"{k}={v!r}" for k, v in self.to_dict().items())
207-
return f"{type(self).__name__}({items})"
92+
def _save(self) -> None:
93+
with self._path.open("w") as f:
94+
json.dump(validate_config(self.config), f)
20895

20996

21097
def find_build_config_item_in_python_file(
21198
module_name: str, path: Path
212-
) -> Optional[BuildConfigItem]:
99+
) -> Optional[_ConfigItem]:
213100
with path.open() as f:
214101
return find_build_config_item_in_python_source(module_name, f.read())
215102

216103

217104
def find_python_packages_build_config_items(
218105
paths: Optional[List[str]] = None,
219-
) -> Tuple[List[BuildConfigItem], List[Exception]]:
106+
) -> Tuple[List[_ConfigItem], List[Exception]]:
220107
"""Find javascript dependencies declared by Python modules
221108
222109
Parameters:
@@ -228,7 +115,7 @@ def find_python_packages_build_config_items(
228115
Mapping of module names to their corresponding list of discovered dependencies.
229116
"""
230117
failures: List[Tuple[str, Exception]] = []
231-
build_configs: List[BuildConfigItem] = []
118+
build_configs: List[_ConfigItem] = []
232119
for module_info in iter_modules(paths):
233120
module_name = module_info.name
234121
module_loader = module_info.module_finder.find_module(module_name)
@@ -250,13 +137,88 @@ def find_python_packages_build_config_items(
250137

251138
def find_build_config_item_in_python_source(
252139
module_name: str, module_src: str
253-
) -> Optional[BuildConfigItem]:
140+
) -> Optional[_ConfigItem]:
254141
for node in ast.parse(module_src).body:
255142
if isinstance(node, ast.Assign) and (
256143
len(node.targets) == 1
257144
and isinstance(node.targets[0], ast.Name)
258145
and node.targets[0].id == "idom_build_config"
259146
):
260-
raw_config = eval(compile(ast.Expression(node.value), "temp", "eval"))
261-
return BuildConfigItem.from_dict(raw_config, source_name=module_name)
147+
config_item = validate_config_item(
148+
eval(compile(ast.Expression(node.value), "temp", "eval"))
149+
)
150+
config_item.setdefault("source_name", module_name)
151+
return config_item
152+
262153
return None
154+
155+
156+
class _DerivedConfigProperties(TypedDict):
157+
js_dependency_aliases_by_source: Dict[str, Dict[str, str]]
158+
aliased_js_dependencies_by_source: Dict[str, List[str]]
159+
160+
161+
def _derive_config_properties(config: Dict[str, Any]) -> _DerivedConfigProperties:
162+
js_dependency_aliases_by_source = {}
163+
aliased_js_dependencies_by_source = {}
164+
for src, cfg in config["by_source"].items():
165+
cfg_hash = _hash_config_item(cfg)
166+
aliases, aliased_js_deps = _config_item_js_dependencies(cfg, cfg_hash)
167+
js_dependency_aliases_by_source[src] = aliases
168+
aliased_js_dependencies_by_source[src] = aliased_js_deps
169+
return {
170+
"js_dependency_aliases_by_source": js_dependency_aliases_by_source,
171+
"aliased_js_dependencies_by_source": aliased_js_dependencies_by_source,
172+
}
173+
174+
175+
def _config_item_js_dependencies(
176+
config_item: Dict[str, Any], config_hash: str
177+
) -> Tuple[Dict[str, str], List[str]]:
178+
alias_suffix = f"{config_item['source_name']}-{config_hash}"
179+
aliases: Dict[str, str] = {}
180+
aliased_js_deps: List[str] = []
181+
for dep in config_item["js_dependencies"]:
182+
dep_name = split_package_name_and_version(dep)[0]
183+
dep_alias = f"{dep_name}-{alias_suffix}"
184+
aliases[dep_name] = dep_alias
185+
aliased_js_deps.append(f"{dep_alias}@npm:{dep}")
186+
return aliases, aliased_js_deps
187+
188+
189+
def _hash_config_item(config_item: Dict[str, Any]) -> str:
190+
conf_hash = sha256(json.dumps(config_item, sort_keys=True).encode())
191+
short_hash_int = (
192+
int(conf_hash.hexdigest(), 16)
193+
# chop off the last 8 digits (no need for that many)
194+
% 10 ** 8
195+
)
196+
return format(short_hash_int, "x")
197+
198+
199+
_CONFIG_SCHEMA = {
200+
"type": "object",
201+
"properties": {
202+
"version": {"type": "string"},
203+
"by_source": {
204+
"type": "object",
205+
"patternProperties": {".*": {"$ref": "#/definitions/ConfigItem"}},
206+
},
207+
},
208+
"definitions": {
209+
"ConfigItem": {
210+
"type": "object",
211+
"properties": {
212+
"source_name": {"type": "string"},
213+
"js_dependencies": {
214+
"type": "array",
215+
"items": {"type": "string"},
216+
},
217+
},
218+
}
219+
},
220+
}
221+
222+
223+
validate_config = compile_schema(_CONFIG_SCHEMA)
224+
validate_config_item = compile_schema(_CONFIG_SCHEMA["definitions"]["ConfigItem"])

requirements/prod.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ async_exit_stack >=1.0.1; python_version<"3.7"
77
jsonpatch >=1.26
88
typer >=0.3.2
99
click-spinner >=0.1.10
10+
fastjsonschema >=2.14.5

0 commit comments

Comments
 (0)