forked from OpenAssetIO/OpenAssetIO-TraitGen
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpython.py
More file actions
268 lines (229 loc) · 9.73 KB
/
Copy pathpython.py
File metadata and controls
268 lines (229 loc) · 9.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
#
# Copyright 2022 The Foundry Visionmongers Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
A traitgen generator that outputs a python package based on the
openassetio_traitgen PackageDefinition model.
"""
import keyword
import logging
import os
import re
from typing import List
import jinja2
from . import helpers
from ..datamodel import PackageDeclaration, PropertyType
__all__ = ["generate"]
#
## Code Generation
#
# pylint: disable=too-many-locals
def generate(
package_declaration: PackageDeclaration,
globals_: dict,
output_directory: str,
creation_callback,
logger: logging.Logger,
):
"""
Generates a python package for the supplied definition under outputDirPath.
"""
env = _create_jinja_env(globals_, logger)
def render_template(name: str, path: str, variables: dict):
"""
A convenience to render a named template into its corresponding
file and call the creationCallback.
"""
# pylint: disable=line-too-long
# NB: Jinja assumes '/' on all plaftorms:
# https://github.com/pallets/jinja/blob/7fb13bf94443f067c74204a1aee368fdf0591764/src/jinja2/loaders.py#L29
template = env.get_template(f"python/{name}.py.in")
with open(path, "w", encoding="utf-8", newline="\n") as file:
file.write(template.render(variables))
creation_callback(path)
def create_dir_with_path_components(*args) -> str:
"""
A convenience to create a directory from the supplied path
components, calling the creationCallback and returning its path
as a string.
"""
path = os.path.join(*args)
os.makedirs(path, exist_ok=True)
creation_callback(path)
return path
# Top level package directory, under a "python" subdirectory
package_name = env.filters["to_py_module_name"](package_declaration.id)
package_dir_path = create_dir_with_path_components(output_directory, package_name)
# Collect which sub-packages we should import at the top level, so
# they're available without a 'from x import y' statement.
package_init_imports = []
# Sub-packages for traits and specifications
for kind in ("traits", "specifications"):
namespaces = getattr(package_declaration, kind, None)
if namespaces:
package_init_imports.append(kind)
# Create the directory for the sub-package
subpackage_dir_path = create_dir_with_path_components(package_dir_path, kind)
# Collect the resulting module names for each namespace
# So we can pre-import them in the sub-package init.
subpackage_init_imports = []
# Generate a single-file module for each namespace
for namespace in namespaces:
safe_namespace = env.filters["to_py_module_name"](namespace.id)
subpackage_init_imports.append(safe_namespace)
render_template(
kind,
os.path.join(subpackage_dir_path, f"{safe_namespace}.py"),
{
"package": package_declaration,
"namespace": namespace,
"imports": helpers.package_dependencies(namespace.members),
},
)
# Generate the sub-package __init__.py that pre-imports all
# of the sub-modules.
subpackage_init_imports.sort()
docstring = f"{kind.capitalize()} defined in the '{package_declaration.id}' package."
render_template(
"__init__",
os.path.join(subpackage_dir_path, "__init__.py"),
{"docstring": docstring, "relImports": subpackage_init_imports},
)
# Package __init__.py
render_template(
"__init__",
os.path.join(package_dir_path, "__init__.py"),
{"docstring": package_declaration.description, "relImports": package_init_imports},
)
#
## Jinja setup
#
def _create_jinja_env(env_globals, logger):
"""
Creates a custom Jinja2 environment with:
- A package a loader that automatically finds templates within a
'templates' directory in the openassetio_traitgen python package.
- Updated globals.
- Custom filters.
"""
env = jinja2.Environment(loader=jinja2.PackageLoader("openassetio_traitgen"))
env.globals.update(env_globals)
_install_custom_filters(env, logger)
return env
# Custom filters
def _install_custom_filters(environment, logger):
"""
Installs custom filters in to the Jinja template environment that allow
data from the model to be conformed to python-specific standards.
The toPy* methods will log a warning if the string is changed from
the input during this process. An error will be raised if this
resulted in an empty string.
"""
def validate_identifier(string: str, original: str):
"""
Validates some string is a legal python variable name.
"""
if not string.isidentifier():
raise ValueError(f"{string}' (from '{original}' is not a valid python identifier.")
if keyword.iskeyword(string):
raise ValueError(f"{string}' (from '{original}' is a reserved python keyword.")
def to_py_module_name(string: str):
"""
Conforms the supplied string a legal module name.
"""
# Don't warn for - to _ as it is expected that setuptools
# creates a safe name from the package name anyway.
# eg: openassetio-traitgen -> openassetio_traitgen
no_hypens = string.replace("-", "_")
module_name = re.sub(r"[^a-zA-Z0-9_]", "_", no_hypens)
if module_name != no_hypens:
_conform_warning(string, module_name, "module name")
return module_name
def to_py_class_name(string: str):
"""
Conforms the supplied string a legal python class name.
"""
class_name = helpers.to_upper_camel_alnum(string)
if class_name != string:
_conform_warning(string, class_name, "class name")
validate_identifier(class_name, string)
return class_name
def to_py_trait_accessor_name(name_parts: List[str]):
"""
Conforms the supplied trait name to a legal function name
beginning with a lowercase letter.
"""
capitalized_parts = [helpers.to_upper_camel_alnum(part) for part in name_parts]
unique_name = "".join(capitalized_parts)
accessor_name = helpers.to_lower_camel_alnum(unique_name)
# We expect the first letter to change to lowercase
if accessor_name != f"{unique_name[0].lower()}{unique_name[1:]}":
_conform_warning(unique_name, accessor_name, "trait getter name")
validate_identifier(accessor_name, unique_name)
return accessor_name
def to_py_var_accessor_name(string: str):
"""
Conforms the supplied string a legal function name, but
beginning with an uppercase letter so it can be prefixed with
get or set.
"""
accessor_name = helpers.to_upper_camel_alnum(string)
if accessor_name != f"{string[0].upper()}{string[1:]}":
_conform_warning(string, accessor_name, "property accessor name")
validate_identifier(accessor_name, string)
return accessor_name
def to_py_var_name(string: str):
"""
Conforms the supplied string to a valid python var name,
starting with a lowercase letter.
"""
var_name = helpers.to_lower_camel_alnum(string)
if var_name != string:
_conform_warning(string, var_name, "variable name")
validate_identifier(var_name, string)
return var_name
type_map = {
PropertyType.STRING: "str",
PropertyType.INTEGER: "int",
PropertyType.FLOAT: "float",
PropertyType.BOOL: "bool",
PropertyType.DICT: "dict", # This must be InfoDictionary, but this isn't bound
}
def to_py_type(declaration_type):
"""
Returns the python value type for a property declaration (PropertyType).
"""
if declaration_type == PropertyType.DICT:
raise TypeError("Dictionary types are not yet supported as trait properties")
return type_map[declaration_type]
def _conform_warning(original: str, conformed: str, context: str):
"""
Log a warning that an input name has been modified to conform
to a valid identifier, if the warning has not already been
logged.
"""
warning = f"Conforming '{original}' to '{conformed}' for {context}"
if warning in environment.globals["conform_warnings"]:
return
environment.globals["conform_warnings"].append(warning)
logger.warning(warning)
environment.globals["conform_warnings"] = []
environment.filters["to_upper_camel_alnum"] = helpers.to_upper_camel_alnum
environment.filters["to_py_module_name"] = to_py_module_name
environment.filters["to_py_class_name"] = to_py_class_name
environment.filters["to_py_trait_accessor_name"] = to_py_trait_accessor_name
environment.filters["to_py_var_accessor_name"] = to_py_var_accessor_name
environment.filters["to_py_var_name"] = to_py_var_name
environment.filters["to_py_type"] = to_py_type