Skip to content

Commit 8a65b25

Browse files
authored
feat: Backlog/monorepo refactor 2025/configuration library (#609)
1 parent 63b45f7 commit 8a65b25

File tree

13 files changed

+1473
-0
lines changed

13 files changed

+1473
-0
lines changed

library/configuration/README.md

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# :coding: utf-8
2+
# :copyright: Copyright (c) 2024 ftrack
3+
4+
# TODO: Change logic to use the generated or provided metadata as the source for further processing
5+
6+
# TODO:
7+
# - implement schema so we can identify if something is a path and format it accordingly
8+
# - (OPTIONAL) provide functionality to inject named regex groups as configuration values via a special resolver (maybe a namespaced one)
9+
# e.g. {regex:maya.launch.executable.version} OR simply {regex:version}
10+
11+
from .configuration import Configuration as Configuration
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# :coding: utf-8
2+
# :copyright: Copyright (c) 2024 ftrack
3+
4+
# TODO: make sure that the sources in the metadata (especially for conflicts) are always in the same order
5+
# TODO: Maybe the configuration can only be in one state at a time
6+
# it can not contain multiple states at the same time. This will ensure
7+
# that the configuration is always in a valid state.
8+
# We'll always clear all successive states when a previous state is changed.
9+
10+
import logging
11+
import tempfile
12+
13+
from pathlib import Path
14+
from typing import Self, Optional
15+
16+
from omegaconf import OmegaConf, DictConfig
17+
18+
from .helper.types import ConfigurationSpec
19+
from .utility.configuration import (
20+
get_configuration_specs_from_entrypoint,
21+
get_configuration_specs_from_namespace,
22+
get_configuration_specs_from_paths,
23+
get_configuration_specs_from_files,
24+
get_conflicts_from_configuration_specs,
25+
get_configuration_keys_by_pattern,
26+
create_metadata_from_configuration_specs,
27+
create_configuration_specs_from_metadata,
28+
save_configuration_to_yaml,
29+
convert_configuration_to_dict,
30+
compose_conflict_keys_in_specific_order_onto_configuration,
31+
compose_configuration_from_configuration_specs,
32+
resolve_configuration,
33+
remove_keys_marked_for_deletion,
34+
remove_keys_by_full_key,
35+
)
36+
from .utility.resolver import register_ft_resolvers
37+
38+
logging.basicConfig(level=logging.INFO)
39+
40+
41+
class Configuration:
42+
"""
43+
This class wraps the configuration process and provides a simple interface to load, compose and resolve configurations.
44+
Where feasible, it exposes a fluent interface to chain the configuration steps e.g.
45+
Configuration().load_from_entrypoint("connect.configuration").compose().resolve().dump("/tmp/configurations")
46+
"""
47+
48+
def __init__(self):
49+
# TODO: when we generate the metadata from the specs, we'll always stop on conflicts
50+
# when metadata is provided by the user, we'll use the CONFLICT_HANDLING
51+
self._register_constructors_and_resolvers()
52+
self._specs: set[ConfigurationSpec] = set()
53+
self._metadata: DictConfig = OmegaConf.create({})
54+
self._conflicts: DictConfig = OmegaConf.create({})
55+
self._conflict_resolution: DictConfig = OmegaConf.create({})
56+
self._composed: DictConfig = OmegaConf.create({})
57+
self._resolved: DictConfig = OmegaConf.create({})
58+
59+
@property
60+
def metadata(self) -> DictConfig:
61+
return self._metadata
62+
63+
@property
64+
def conflicts(self) -> DictConfig:
65+
return self._conflicts
66+
67+
@property
68+
def composed(self) -> DictConfig:
69+
return self._composed
70+
71+
@property
72+
def resolved(self) -> DictConfig:
73+
return self._resolved
74+
75+
@staticmethod
76+
def _register_constructors_and_resolvers() -> None:
77+
register_ft_resolvers()
78+
register_ft_resolvers()
79+
# extend_omegaconf_loader()
80+
81+
def load_from_entrypoint(self, entrypoint: str) -> Self:
82+
self._specs = self._specs.union(
83+
get_configuration_specs_from_entrypoint(entrypoint)
84+
)
85+
self._generate_metadata_from_specs()
86+
return self
87+
88+
def load_from_namespace(self, namespace: str, module_name: str) -> Self:
89+
self._specs = self._specs.union(
90+
get_configuration_specs_from_namespace(namespace, module_name)
91+
)
92+
self._generate_metadata_from_specs()
93+
return self
94+
95+
def load_from_paths(self, paths: list[Path]) -> Self:
96+
self._specs = self._specs.union(get_configuration_specs_from_paths(paths))
97+
self._generate_metadata_from_specs()
98+
return self
99+
100+
def load_from_files(self, files: list[Path]) -> Self:
101+
self._specs = self._specs.union(get_configuration_specs_from_files(files))
102+
self._generate_metadata_from_specs()
103+
return self
104+
105+
def load_from_metadata_file(self, path: Path) -> Self:
106+
loaded_metadata = OmegaConf.load(path)
107+
specs = create_configuration_specs_from_metadata(loaded_metadata["_metadata"])
108+
computed_metadata = create_metadata_from_configuration_specs(specs)
109+
# TODO: Be more specific in the handling of this case.
110+
assert (
111+
loaded_metadata == computed_metadata
112+
), "The loaded metadata does not match the generated metadata."
113+
# TODO: We might want to avoid storing the specs and compute everything directly
114+
# on-the-fly from the metadata.
115+
self._specs = specs
116+
self._metadata = computed_metadata
117+
return self
118+
119+
def load_conflict_resolution(self, path: Path) -> Self:
120+
self._conflict_resolution = OmegaConf.load(path)
121+
return self
122+
123+
def _generate_metadata_from_specs(self) -> Self:
124+
self._metadata = create_metadata_from_configuration_specs(self._specs)
125+
return self
126+
127+
def _check_configuration_specs_for_conflicts(self) -> Self:
128+
self._conflicts["conflicts"] = get_conflicts_from_configuration_specs(
129+
self._specs, ["configuration"]
130+
)
131+
return self
132+
133+
def clear(self) -> Self:
134+
self._specs = set()
135+
self._metadata = OmegaConf.create({})
136+
self._composed = OmegaConf.create({})
137+
self._resolved = OmegaConf.create({})
138+
return self
139+
140+
def compose(self, conflict_resolution_file: Optional[Path] = None) -> Self:
141+
self._check_configuration_specs_for_conflicts()
142+
specs = create_configuration_specs_from_metadata(self._metadata["_metadata"])
143+
self._composed = compose_configuration_from_configuration_specs(specs)
144+
if self._conflicts and not conflict_resolution_file:
145+
pass
146+
# raise ValueError("Conflicts detected in the configuration.")
147+
# TODO: We're already creating the specs when loading the metadata from a file.
148+
# We should be more consistent and either ONLY use the metadata, or ONLY use the specs.
149+
else:
150+
conflicts = OmegaConf.load(conflict_resolution_file)
151+
self._composed = compose_conflict_keys_in_specific_order_onto_configuration(
152+
self._composed, self._metadata, conflicts
153+
)
154+
return self
155+
156+
def resolve(self, clean=True) -> Self:
157+
self._resolved = resolve_configuration(self._composed)
158+
if clean:
159+
self._resolved = remove_keys_marked_for_deletion(self._resolved)
160+
metadata_keys = get_configuration_keys_by_pattern(
161+
self._resolved, r"^.*_metadata$"
162+
)
163+
self._resolved = remove_keys_by_full_key(self._resolved, metadata_keys)
164+
return self
165+
166+
@staticmethod
167+
def as_dict(configuration: DictConfig) -> dict:
168+
return convert_configuration_to_dict(configuration)
169+
170+
@staticmethod
171+
def to_yaml(configuration, filepath: Path) -> None:
172+
save_configuration_to_yaml(configuration, filepath)
173+
174+
def dump(self, folder: Path) -> bool:
175+
"""
176+
Dumps all configuration steps to the given folder.
177+
178+
:param folder: The folder to dump the configurations to.
179+
:return: Success
180+
"""
181+
# First check if we have a valid folder and we can write to it.
182+
folder = Path(folder)
183+
try:
184+
with tempfile.TemporaryFile(dir=folder):
185+
pass
186+
except Exception:
187+
logging.error(
188+
f"{folder} is not a valid folder, can't be written to or does not exist."
189+
)
190+
return False
191+
192+
save_configuration_to_yaml(self._metadata, folder / "metadata.yaml")
193+
save_configuration_to_yaml(self._conflicts, folder / "conflicts.yaml")
194+
save_configuration_to_yaml(self._composed, folder / "composed.yaml")
195+
save_configuration_to_yaml(self._resolved, folder / "resolved.yaml")
196+
197+
return True
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
_metadata:
2+
ignore: True # ignore this config file
3+
4+
connect:
5+
6+
launcher:
7+
common:
8+
_metadata:
9+
marked_for_deletion: true
10+
context:
11+
- Task
12+
- Shot
13+
environment-variables:
14+
ARNOLD_PLUGIN_PATH: "/path/to/arnold/plugin"
15+
TRACTOR_SERVER: "http://trartor:80"
16+
17+
18+
houdini-default:
19+
icon: houdini
20+
application-name: houdini
21+
context: ${..common.context}
22+
environment-variables: ${..common.environment-variables}
23+
application-collect:
24+
_metadata:
25+
marked_for_deletion: true
26+
python-packages:
27+
any:
28+
- name: ftrack-utilities
29+
version: 1.4.8
30+
"3.7":
31+
- name: ftrack-connect-package
32+
version: 1.0.0
33+
- name: ftrack-framework
34+
version: 3.0.0
35+
"3.9":
36+
- name: ftrack-connect-package
37+
version: 2.0.0
38+
- name: ftrack-framework
39+
version: 3.5.0
40+
windows:
41+
executable: ${ft.glob:C:\Program Files\Side Effects Software\Houdini*\bin\houdini*.exe}
42+
version: ${ft.regex.search:${.executable}, "(\d+\.\d+\.\d+)", "0.0.0"}
43+
variant: ${ft.regex.search:${.executable}, ".*bin\\houdini(.*)\.exe", "default"}
44+
python: ${ft.regex.sub:${.executable}, "hython.exe", "(houdini.*?\.exe)"}
45+
python-version: ${ft.regex.search:${ft.exec:${.python},--version},"(\d+\.\d+\.\d+)"}
46+
identifier: ${ft.join:${ft.zip.list:${ft.key:3},${.version},${.variant},${.python-version}}}
47+
python-packages: ${ft.select:${..python-packages}, ${ft.regex.search:${.python-version}, "(\d+\.\d+)", "any"}}
48+
linux:
49+
executable: ${ft.glob:C:\Program Files\Side Effects Software\Houdini*\bin\houdini*.exe}
50+
version: ${ft.regex.search:${.executable}, "(\d+\.\d+\.\d+)", "0.0.0"}
51+
variant: ${ft.regex.search:${.executable}, ".*bin\\houdini(.*)\.exe", "default"}
52+
python: ${ft.regex.sub:${.executable}, "hython.exe", "(houdini.*?\.exe)"}
53+
python-version: ${ft.regex.search:${ft.exec:${.python},--version},"(\d+\.\d+\.\d+)"}
54+
identifier: ${ft.join:${ft.zip.list:${ft.key:3},${.version},${.variant},${.python-version}}}
55+
python-packages: ${ft.select:${..python-packages}, ${ft.regex.search:${.python-version}, "(\d+\.\d+)", "any"}}
56+
57+
# TODO: can we push the identifier to the top level?
58+
platform:
59+
windows: ${ft.zip.dict:${..application-collect.windows}}
60+
linux: ${ft.zip.dict:${..application-collect.linux}}
61+
62+
maya-default:
63+
icon: maya
64+
application-name: maya
65+
context: ${..common.context}
66+
environment-variables: ${..common.environment-variables}
67+
application-collect:
68+
_metadata:
69+
marked_for_deletion: true
70+
executable: ${ft.glob:C:\Program Files\Autodesk\Maya*\bin\maya.exe}
71+
version: ${ft.regex.search:${.executable}, "\d{4}"}
72+
variant: ${ft.regex.search:${.executable}, ".*bin\\maya(.*)\.exe", "default"}
73+
python: ${ft.regex.sub:${.executable}, "mayapy.exe", "(maya.*?\.exe)"}
74+
python-version: ${ft.regex.search:${ft.exec:${.python},--version},"(\d+\.\d+\.\d+)"}
75+
identifier: ${ft.join:${ft.zip.list:${ft.key:2},${.version},${.variant},${.python-version}}}
76+
77+
application: ${ft.zip.dict:${.application-collect}}
78+
79+
blender-default:
80+
icon: blender
81+
application-name: blender
82+
context: ${..common.context}
83+
application-collect:
84+
_metadata:
85+
marked_for_deletion: true
86+
# TODO: When we have multiple search patterns, we need to be able to flatten the list of lists.
87+
executable: ${ft.glob:"${environment.HOME}\scoop\persist\blender-launcher\stable\blender-*\blender.exe"}
88+
version: ${ft.regex.search:${.executable}, "\d+\.\d+\.\d+"}
89+
variant: ${ft.regex.search:${.executable}, ".*(lts|stable).*\\blender.exe", "default"}
90+
python: ${ft.glob:"${environment.HOME}\scoop\persist\blender-launcher\stable\blender-*\*\python\bin\python.exe"}
91+
python-version: ${ft.regex.search:${ft.exec:${.python},--version},"(\d+\.\d+\.\d+)"}
92+
identifier: ${ft.join:${ft.zip.list:${ft.key:2},${.version},${.variant},${.python-version}}}
93+
application: ${ft.zip.dict:${.application-collect}}
94+
95+
authenticator:
96+
username: ${ft.runtime.cached:username}
97+
password: ${ft.runtime.cached:password}
98+
99+
framework:
100+
tool:
101+
102+
# TODO: Figure out how to NOT resolve these until explicitly asking for it.
103+
runtime:
104+
username: ${ft.lower:${ft.runtime.cached:username}}
105+
architecture: ${ft.lower:${ft.runtime.cached:architecture}}
106+
platform: ${ft.lower:${ft.runtime.cached:platform}}
107+
hostname: ${ft.lower:${ft.runtime.cached:hostname}}
108+
python_version: ${ft.runtime.cached:python_version}
109+
startup_time: ${ft.runtime.cached:time}
110+
current_time: ${ft.runtime.live:time}
111+
112+
path:
113+
config: ${ft.paths:config}
114+
connect-installation: ${ft.paths:config}
115+
tool-installation: ${ft.paths:config}
116+
log: ${ft.paths:log}
117+
118+
environment:
119+
HOME: ${oc.env:HOME}
120+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
runtime:
2+
username: ${ft.lower:${ft.runtime.cached:username}}
3+
architecture: ${ft.lower:${ft.runtime.cached:architecture}}
4+
platform: ${ft.lower:${ft.runtime.cached:platform}}
5+
hostname: ${ft.lower:${ft.runtime.cached:hostname}}
6+
python_version: ${ft.runtime.cached:python_version}
7+
startup_time: ${ft.runtime.cached:time}
8+
current_time: ${ft.runtime.live:time}
9+
paths:
10+
config_dir: ${ft.paths:config}
11+
connect_installation_dir: ${ft.paths:config}
12+
log_dir: ${ft.paths:log}

library/configuration/ftrack/library/configuration/helper/__init__.py

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from enum import Enum
2+
3+
4+
class METADATA(Enum):
5+
ROOT = "_metadata"
6+
SOURCES = "sources"
7+
DELETE = "marked_for_deletion"
8+
CONFLICTS = "conflicts"
9+
10+
11+
class CONFLICT_HANDLING(Enum):
12+
WARN = "warn"
13+
RAISE = "raise"

0 commit comments

Comments
 (0)