22
33import json
44import ast
5+ from copy import deepcopy
56from functools import wraps
67from contextlib import contextmanager
78from hashlib import sha256
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
2327from .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
3136def _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
21097def 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
217104def 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
251138def 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" ])
0 commit comments