Skip to content

Commit aa5abef

Browse files
committed
[Core] Add trait and specification versioning
Closes #80. Update the schema to support a dictionary of "versions" for traits and specifications, where each version contains a full trait/specification definition. Add a "deprecated" tag to the schema, allowing traits and specifications to be flagged as due for complete removal (i.e. all versions). Use string keys for the version, rather than integer keys, to provide maximum scope for future extension (e.g. tagged, semver). Note: this decision was not called out in the original design. Add a `.v1` suffix to trait IDs and an `_v1` suffix to trait and specification class names (where `1` is the version string). Do not add a version suffix to the ID for the first version of a trait, to maintain backward compatibility. Add trait classes that match the previous scheme, i.e. with no `_v1` suffix, which alias the first version of a trait, to maintain backward compatibility. Use inheritance to ensure maximum compatibility, rather than duplicating the class. Similarly to traits, add unversioned Specification view classes for backward compatibility. Non-versioned view classes are only for backward compatibility - going forward, hosts/managers should be explicit and use the version-suffixed classes. So mark non-versioned view classes as deprecated in their docstrings. For Python, add a `DeprecationWarning` using the `warnings` module when an unversioned view class is first constructed. Use the class name in the warning text, so a separate warning will be generated for each non-migrated class. For C++, add `[[deprecated]]` annotation to non-versioned view classes. Add deprecation docstrings and warnings for traits and specifications flagged with `deprecated: true`. Flag warnings at runtime in Python using the `warnings` module and C++ at compile time using the `[[deprecated]]` annotation. Update log output from command-line script to add version numbers. Since we're now using the `to_python_*_name` filters multiple times for the same input (in particular, class names), any names modified to be valid Python identifiers duplicate the same "Conforming" warning logs several times. So de-duplicate by keeping a cache of warnings that have already been logged. Add version specifiers to `TraitDeclaration`, `TraitReference` and `SpecificationDeclaration` for use in constructing IDs and class names. Add a CTest target for testing C++ deprecation warnings. Suppress at the compiler warning level, other than for the explicit deprecation warning test. Unfortunately, testing multiple raw string matches in build output via CTest is rather involved, but can be made to work. Signed-off-by: David Feltell <david.feltell@foundry.com>
1 parent 3776797 commit aa5abef

25 files changed

Lines changed: 1729 additions & 498 deletions

RELEASE_NOTES.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
Release Notes
22
=============
33

4+
v1.0.0-alpha.x
5+
--------------
6+
7+
### Breaking changes
8+
9+
- Updated the YAML schema to group traits/specifications under a version
10+
number key.
11+
[#80](https://github.com/OpenAssetIO/OpenAssetIO-TraitGen/issues/80)
12+
13+
### New features
14+
15+
- Added support for trait versioning. Suffixed generated
16+
trait/specification view classes with a `_vX` and trait IDs with a `.vX`
17+
(where `X` is a version number), except for the first version of a
18+
trait, where the ID has no version suffix, retaining backward
19+
compatibility.
20+
[#80](https://github.com/OpenAssetIO/OpenAssetIO-TraitGen/issues/80)
21+
22+
- Added an optional `deprecated` field to trait and specification YAML
23+
definitions, which causes a deprecation warning/annotation to be
24+
generated for all versions of that trait/specification.
25+
[#80](https://github.com/OpenAssetIO/OpenAssetIO-TraitGen/issues/80)
26+
27+
### Improvements
28+
29+
- Updated classes without a version suffix to alias version 1, but with
30+
a deprecation warning/annotation, for backward compatibility.
31+
[#80](https://github.com/OpenAssetIO/OpenAssetIO-TraitGen/issues/80)
32+
33+
434
v1.0.0-alpha.12
535
--------------
636

python/openassetio_traitgen/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,10 @@ def _log_package_declaration(package, logger):
176176
for namespace in package.traits:
177177
logger.info(f"{namespace.id}:")
178178
for trait in namespace.members:
179-
logger.info(" - %s", trait.name)
179+
logger.info(" - %s (v%s)", trait.name, trait.version)
180180
if package.specifications:
181181
logger.info("Specifications:")
182182
for namespace in package.specifications:
183183
logger.info(f"{namespace.id}:")
184184
for specification in namespace.members:
185-
logger.info(" - %s", specification.id)
185+
logger.info(" - %s (v%s)", specification.id, specification.version)

python/openassetio_traitgen/datamodel.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ class TraitDeclaration(NamedTuple):
8282
# A short name for the Trait that is only unique within its
8383
# namespace.
8484
name: str
85+
# Whether this trait is deprecated.
86+
deprecated: bool
87+
# Version of the trait.
88+
version: str
8589
# A user-facing description of the Trait and its purpose.
8690
description: str
8791
# User-facing hints as to the usage of this trait, in relation to
@@ -115,6 +119,8 @@ class TraitReference(NamedTuple):
115119
namespace: str
116120
# The package the trait belongs to
117121
package: str
122+
# Version of the trait
123+
version: str
118124
# The shortest list of elements from package, namespace and name
119125
# that is required to form a unique name for this trait
120126
# relative to the specification. These should be used
@@ -132,6 +138,10 @@ class SpecificationDeclaration(NamedTuple):
132138

133139
# The unique name of Specification within its namespace.
134140
id: str
141+
# Whether this specification is deprecated.
142+
deprecated: bool
143+
# Version of the specification.
144+
version: str
135145
# A user-facing description of the Specification and its purpose.
136146
description: str
137147
# User-facing hints as to the usage of this trait, in relation to

python/openassetio_traitgen/generators/cpp.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
A traitgen generator that outputs a C++ package based on the
1818
openassetio_traitgen PackageDefinition model.
1919
"""
20+
import collections
21+
import itertools
22+
2023
# TODO(DF): Refactor to pull out common code, then remove this
2124
# suppression.
2225
# pylint: disable=duplicate-code
@@ -180,12 +183,27 @@ def __render_namespace(
180183
)
181184
imports = []
182185

183-
# Render a file per class (trait or specification).
184-
for declaration in namespace.members:
186+
# We group multiple versions of the same trait (or
187+
# specification) together to render in the same header. Note
188+
# that declarations in a namespace are already sorted
189+
# appropriately by name, so we don't need to sort before
190+
# applying groupby.
191+
declarations_by_name = itertools.groupby(
192+
namespace.members,
193+
lambda declaration: declaration.name if kind == "traits" else declaration.id,
194+
)
195+
196+
# Render a file per trait/specification, containing all
197+
# versions of that trait/specification.
198+
for name, declarations in declarations_by_name:
185199
if kind == "traits":
186-
file_name = self.__render_trait(namespace, declaration, namespace_abs_path)
200+
file_name = self.__render_trait(
201+
namespace, name, tuple(declarations), namespace_abs_path
202+
)
187203
else:
188-
file_name = self.__render_specification(namespace, declaration, namespace_abs_path)
204+
file_name = self.__render_specification(
205+
namespace, name, tuple(declarations), namespace_abs_path
206+
)
189207

190208
imports.append(f"{namespace_name}/{file_name}")
191209

@@ -207,7 +225,8 @@ def __render_namespace(
207225
def __render_trait(
208226
self,
209227
namespace: NamespaceDeclaration,
210-
declaration: TraitDeclaration,
228+
name: str,
229+
declarations: tuple[TraitDeclaration, ...],
211230
namespace_abs_path: str,
212231
) -> str:
213232
"""
@@ -216,24 +235,25 @@ def __render_trait(
216235
Creates a single header file containing a single trait view
217236
class.
218237
"""
219-
cls_name = self.__env.filters["to_cpp_class_name"](declaration.name) + "Trait"
238+
header_name = self.__env.filters["to_cpp_class_name"](name) + "Trait"
220239
self.__render_template(
221240
"trait",
222-
os.path.join(namespace_abs_path, f"{cls_name}.hpp"),
241+
os.path.join(namespace_abs_path, f"{header_name}.hpp"),
223242
{
224243
"package": self.__package,
225244
"namespace": namespace,
226-
"trait": declaration,
245+
"versions": declarations,
227246
"openassetio_abi_version": OPENASSETIO_ABI_VERSION,
228247
"traitgen_abi_version": TRAITGEN_ABI_VERSION,
229248
},
230249
)
231-
return f"{cls_name}.hpp"
250+
return f"{header_name}.hpp"
232251

233252
def __render_specification(
234253
self,
235254
namespace: NamespaceDeclaration,
236-
declaration: SpecificationDeclaration,
255+
name: str,
256+
declarations: tuple[SpecificationDeclaration, ...],
237257
namespace_abs_path: str,
238258
) -> str:
239259
"""
@@ -242,19 +262,38 @@ def __render_specification(
242262
Creates a single header file containing a single specification
243263
class.
244264
"""
245-
cls_name = self.__env.filters["to_cpp_class_name"](declaration.id) + "Specification"
265+
266+
# Properties required to interpolate when constructing #include
267+
# directives.
268+
TraitHeaderPathTokens = collections.namedtuple(
269+
"TraitHeaderPathTokens", ("package", "namespace", "name")
270+
)
271+
272+
# All versions of a given trait live in a single header.
273+
# Extract fields required to #include the trait headers
274+
# referenced by all versions of this specification, de-duped.
275+
all_trait_header_path_tokens = sorted(
276+
{
277+
TraitHeaderPathTokens(trait_decl.package, trait_decl.namespace, trait_decl.name)
278+
for spec_decl in declarations
279+
for trait_decl in spec_decl.trait_set
280+
}
281+
)
282+
283+
header_name = self.__env.filters["to_cpp_class_name"](name) + "Specification"
246284
self.__render_template(
247285
"specification",
248-
os.path.join(namespace_abs_path, f"{cls_name}.hpp"),
286+
os.path.join(namespace_abs_path, f"{header_name}.hpp"),
249287
{
250288
"package": self.__package,
251289
"namespace": namespace,
252-
"specification": declaration,
290+
"versions": declarations,
291+
"all_trait_header_path_tokens": all_trait_header_path_tokens,
253292
"openassetio_abi_version": OPENASSETIO_ABI_VERSION,
254293
"traitgen_abi_version": TRAITGEN_ABI_VERSION,
255294
},
256295
)
257-
return f"{cls_name}.hpp"
296+
return f"{header_name}.hpp"
258297

259298
def __render_package_template(
260299
self, package_abs_path: str, name: str, docstring: str, imports: List[str]

python/openassetio_traitgen/generators/python.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def to_py_module_name(string: str):
181181
no_hypens = string.replace("-", "_")
182182
module_name = re.sub(r"[^a-zA-Z0-9_]", "_", no_hypens)
183183
if module_name != no_hypens:
184-
logger.warning(f"Conforming '{string}' to '{module_name}' for module name")
184+
_conform_warning(string, module_name, "module name")
185185
return module_name
186186

187187
def to_py_class_name(string: str):
@@ -190,7 +190,7 @@ def to_py_class_name(string: str):
190190
"""
191191
class_name = helpers.to_upper_camel_alnum(string)
192192
if class_name != string:
193-
logger.warning(f"Conforming '{string}' to '{class_name}' for class name")
193+
_conform_warning(string, class_name, "class name")
194194
validate_identifier(class_name, string)
195195
return class_name
196196

@@ -204,9 +204,7 @@ def to_py_trait_accessor_name(name_parts: List[str]):
204204
accessor_name = helpers.to_lower_camel_alnum(unique_name)
205205
# We expect the first letter to change to lowercase
206206
if accessor_name != f"{unique_name[0].lower()}{unique_name[1:]}":
207-
logger.warning(
208-
f"Conforming '{unique_name}' to '{accessor_name}' for trait getter name"
209-
)
207+
_conform_warning(unique_name, accessor_name, "trait getter name")
210208
validate_identifier(accessor_name, unique_name)
211209
return accessor_name
212210

@@ -218,9 +216,7 @@ def to_py_var_accessor_name(string: str):
218216
"""
219217
accessor_name = helpers.to_upper_camel_alnum(string)
220218
if accessor_name != f"{string[0].upper()}{string[1:]}":
221-
logger.warning(
222-
f"Conforming '{string}' to '{accessor_name}' for property accessor name"
223-
)
219+
_conform_warning(string, accessor_name, "property accessor name")
224220
validate_identifier(accessor_name, string)
225221
return accessor_name
226222

@@ -231,7 +227,7 @@ def to_py_var_name(string: str):
231227
"""
232228
var_name = helpers.to_lower_camel_alnum(string)
233229
if var_name != string:
234-
logger.warning(f"Conforming '{string}' to '{var_name}' for variable name")
230+
_conform_warning(string, var_name, "variable name")
235231
validate_identifier(var_name, string)
236232
return var_name
237233

@@ -251,6 +247,19 @@ def to_py_type(declaration_type):
251247
raise TypeError("Dictionary types are not yet supported as trait properties")
252248
return type_map[declaration_type]
253249

250+
def _conform_warning(original: str, conformed: str, context: str):
251+
"""
252+
Log a warning that an input name has been modified to conform
253+
to a valid identifier, if the warning has not already been
254+
logged.
255+
"""
256+
warning = f"Conforming '{original}' to '{conformed}' for {context}"
257+
if warning in environment.globals["conform_warnings"]:
258+
return
259+
environment.globals["conform_warnings"].append(warning)
260+
logger.warning(warning)
261+
262+
environment.globals["conform_warnings"] = []
254263
environment.filters["to_upper_camel_alnum"] = helpers.to_upper_camel_alnum
255264
environment.filters["to_py_module_name"] = to_py_module_name
256265
environment.filters["to_py_class_name"] = to_py_class_name

python/openassetio_traitgen/parser.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,14 @@ def _unpack_specifications(model: dict, package_id: str) -> List[datamodel.Names
8888
specifications = [
8989
datamodel.SpecificationDeclaration(
9090
id=name,
91+
deprecated=props.get("deprecated", False),
92+
version=version_num,
9193
description=definition.get("description", "").strip(),
9294
trait_set=_unpack_trait_set(definition["traitSet"], package_id),
9395
usage=definition.get("usage", []),
9496
)
95-
for name, definition in data["members"].items()
97+
for name, props in data["members"].items()
98+
for version_num, definition in props["versions"].items()
9699
]
97100
specifications.sort(key=_byId)
98101

@@ -134,8 +137,9 @@ def _unpack_trait_set(trait_set: List[dict], package_id: str) -> List[datamodel.
134137
package = trait.get("package", package_id)
135138
namespace = trait["namespace"]
136139
name = trait["name"]
140+
version = trait["version"]
137141

138-
identifier = _build_trait_id(package, namespace, name)
142+
identifier = _build_trait_id(package, namespace, name, version)
139143

140144
# Check to see which of the possible combinations of reference
141145
# parts is unique for this trait.
@@ -153,6 +157,7 @@ def _unpack_trait_set(trait_set: List[dict], package_id: str) -> List[datamodel.
153157
name=name,
154158
namespace=namespace,
155159
package=package,
160+
version=version,
156161
unique_name_parts=unique_name_parts,
157162
)
158163
)
@@ -162,7 +167,15 @@ def _unpack_trait_set(trait_set: List[dict], package_id: str) -> List[datamodel.
162167
return references
163168

164169

165-
def _build_trait_id(package: str, namespace: str, name: str) -> str:
170+
def _build_trait_id(package: str, namespace: str, name: str, version: str) -> str:
171+
"""
172+
Builds a trait ID from the supplied components.
173+
174+
The first version "1" omits the version suffix to maintain backward
175+
compatibility with existing traits.
176+
"""
177+
if version != "1":
178+
return f"{package}:{namespace}.{name}.v{version}"
166179
return f"{package}:{namespace}.{name}"
167180

168181

@@ -180,13 +193,16 @@ def _unpack_traits(
180193
for namespace, data in model.items():
181194
traits = [
182195
datamodel.TraitDeclaration(
183-
id=_build_trait_id(package_id, namespace, name),
196+
id=_build_trait_id(package_id, namespace, name, version_num),
184197
name=name,
198+
deprecated=props.get("deprecated", False),
199+
version=version_num,
185200
description=definition.get("description", "").strip(),
186201
properties=_unpack_properties(definition.get("properties", {})),
187202
usage=definition.get("usage", []),
188203
)
189-
for name, definition in data["members"].items()
204+
for name, props in data["members"].items()
205+
for version_num, definition in props["versions"].items()
190206
]
191207
traits.sort(key=_byName)
192208

0 commit comments

Comments
 (0)