Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Omittable Distro support per URI config #105

Merged
merged 8 commits into from
Oct 8, 2024
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,32 @@ the `-r`/`--requirement` option.
Users can see the optional distros in the dump output with verbosity level of 1
or higher. `hab dump - -v`

### Omittable Distros

The `omittable_distros` key in [config](#config) definitions are used to specify distros
that are not required to use this hab configuration. This can be used to make it
so not all hosts need to have a dcc installed. For example a producer likely will
never need to open houdini but does need access to external tools. You would need
to install Houdini(or create a empty .hab.json distro) so hab doesn't raise an
`InvalidRequirementError` when it can't find Houdini.

```json5
"distros": [
"houdini20.0==20.0.688",
"SideFXLabs20.0==20.0.506",
"python_tools"
],
"omittable_distros": [
"houdini20.0",
"SideFXLabs20.0"
]
```
This will make it so `houdini20.0` and `SideFXLabs20.0` will be loaded if found,
but if not they will be ignored. `python_tools` will always need to be installed.

Note: `omittable_distros` is a list of distro names. It does not accept specifier
arguments like `==20.0.688`.

### Platform specific code

Hab works on windows, linux and osx(needs tested). To make it easier to handle
Expand Down
4 changes: 2 additions & 2 deletions hab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ def __init__(
def log_context(cls, uri):
"""Writes a logger.info call for the given uri string or dictionary."""
if isinstance(uri, dict):
logger.info("Context: {}".format(uri["uri"]))
logger.info(f"Context: {uri['uri']}")
else:
logger.info("Context: {}".format(uri))
logger.info(f"Context: {uri}")

@property
def resolver(self):
Expand Down
11 changes: 11 additions & 0 deletions hab/parsers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,19 @@ def load(self, filename):
data = super().load(filename)
self._alias_mods = data.get("alias_mods", NotSet)
self.inherits = data.get("inherits", NotSet)
if self.omittable_distros is NotSet:
self.omittable_distros = data.get("omittable_distros", NotSet)
return data

@hab_property(verbosity=3, process_order=50)
def omittable_distros(self):
"""A collection of distro names that are ignored if required by distros."""
return self.frozen_data.get("omittable_distros", NotSet)

@omittable_distros.setter
def omittable_distros(self, value):
self.frozen_data["omittable_distros"] = value

@hab_property(verbosity=1, group=0)
def uri(self):
# Mark uri as a HabProperty so it is included in _properties
Expand Down
6 changes: 2 additions & 4 deletions hab/parsers/distro_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ def check_ignored_version():
if self.dirname.name in self.resolver.ignored:
# This object is not added to the forest until super is called
raise _IgnoredVersionError(
'Skipping "{}" its dirname is in the ignored list.'.format(
filename
)
f'Skipping "{filename}" its dirname is in the ignored list.'
) from None

try:
Expand Down Expand Up @@ -116,7 +114,7 @@ def load(self, filename):

# The name should be the version == specifier.
self.distro_name = data.get("name")
self.name = "{}=={}".format(self.distro_name, self.version)
self.name = f"{self.distro_name}=={self.version}"

self.aliases = self.standardize_aliases(data.get("aliases", NotSet))
# Store any alias_mods, they will be processed later when flattening
Expand Down
8 changes: 5 additions & 3 deletions hab/parsers/flat_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ def alias_mods(self):
"""
return super().alias_mods

# Note: 'alias_mods' and 'distros' needs to be processed before 'environment'
@hab_property(verbosity=2, process_order=80)
# Note: 'alias_mods', 'distros', versions needs to be processed before 'environment'
@hab_property(verbosity=2, process_order=110)
def environment(self):
"""A resolved set of environment variables for this platform that should
be applied to configure an environment. Any values set to None indicate
Expand Down Expand Up @@ -223,7 +223,9 @@ def versions(self):
self._alias_mods = {}
self.frozen_data["versions"] = versions

reqs = self.resolver.resolve_requirements(distros)
reqs = self.resolver.resolve_requirements(
distros, omittable=self.omittable_distros
)
for req in reqs.values():
version = self.resolver.find_distro(req)
versions.append(version)
Expand Down
137 changes: 84 additions & 53 deletions hab/parsers/hab_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,12 @@ def __init__(self, forest, resolver, filename=None, parent=None, root_paths=None

def __repr__(self):
cls = type(self)
return "{}.{}('{}')".format(cls.__module__, cls.__name__, self.fullpath)
return f"{cls.__module__}.{cls.__name__}('{self.fullpath}')"

def _cache(self):
return {}

def _collect_values(self, node, props=None, default=False):
def _collect_values(self, node, props=None):
"""Recursively process this config node and its parents until all
missing_values have been resolved or we run out of parents.

Expand All @@ -80,47 +80,80 @@ def _collect_values(self, node, props=None, default=False):
they are not NotSet.
props (list, optional): The props to process, if None is
passed then uses `hab_property` values respecting sort_key.
default (bool, optional): Enables processing the default nodes as
part of this methods recursion. Used for internal tracking.
"""
logger.debug("Loading node: {} inherits: {}".format(node.name, node.inherits))
logger.debug(f"Loading node: {node.name} inherits: {node.inherits}")
if props is None:
props = sorted(
self._properties, key=lambda i: self._properties[i].sort_key()
)

self._missing_values = False
# Use sort_key to ensure the props are processed in the correct order
default_cache = {}
for attrname in props:
if getattr(self, attrname) != NotSet:
continue
if attrname == "alias_mods":
if hasattr(node, "alias_mods") and node.alias_mods:
self._alias_mods = {}
# Format the alias environment at this point so any path
# based variables like {relative_root} are resolved against
# the node's directory not the alias being modified
mods = node.format_environment_value(node.alias_mods)
for name, mod in mods.items():
self._alias_mods.setdefault(name, []).append(mod)
continue
value = getattr(node, attrname)
if value is NotSet:
self._missing_values = True
else:
setattr(self, attrname, value)
self._resolve_inherited_value(node, attrname, default_cache)

def _resolve_inherited_value(self, node, attrname, default_cache, default=False):
"""Recursively process this config node and its parents until the requested
attribute has been resolved or we run out of parents.

Args:
node (HabBase): This node's values are copied to self as long as
they are not NotSet.
attrname (str): The name of the attribute to resolve and set on node.
default_cache (dict): Used to cache any required resolving of default
configs to prevent other attrnames from having to re-resolve.
default (bool, optional): Enables processing the default nodes as
part of this methods recursion. Used for internal tracking.

Returns:
bool: If the property value was resolved.
"""

if node.inherits and self._missing_values:
def resolve_parent_node():
if not node.inherits:
return False
parent = node.parent
if parent:
return self._collect_values(parent, props=props, default=default)
elif not default and "default" in self.forest:
# Start processing the default setup
default = True
default_node = self.resolver.closest_config(node.fullpath, default=True)
self._collect_values(default_node, props=props, default=default)
if not parent and not default and "default" in self.forest:
# No more parents to process, try processing the default tree.
fullpath = node.fullpath
if fullpath in default_cache:
parent = default_cache[fullpath]
else:
parent = self.resolver.closest_config(node.fullpath, default=True)
# Don't waste time calling closest_config again for other attrname's
default_cache[fullpath] = parent
if parent:
return self._resolve_inherited_value(
parent, attrname, default_cache, default=True
)
if not parent:
return False
return self._resolve_inherited_value(parent, attrname, default_cache)

return self._missing_values
if getattr(self, attrname) != NotSet:
return True
if attrname == "alias_mods":
if hasattr(node, "alias_mods") and node.alias_mods:
self._alias_mods = {}
# Format the alias environment at this point so any path
# based variables like {relative_root} are resolved against
# the node's directory not the alias being modified
mods = node.format_environment_value(node.alias_mods)
for name, mod in mods.items():
self._alias_mods.setdefault(name, []).append(mod)
return True
if not hasattr(node, attrname) and isinstance(node, self._placeholder):
# Skip properties that don't exist on the placeholder class
# recursively check parent
return resolve_parent_node()
value = getattr(node, attrname)
if value is NotSet:
# recursively check parent
return resolve_parent_node()

# Store the resolved value and finish
setattr(self, attrname, value)
return True

@classmethod
def _dump_versions(cls, value, verbosity=0, color=None):
Expand Down Expand Up @@ -166,11 +199,11 @@ def check_environment(self, environment_config):
msg = None
if operation == "set":
key = environment_config[operation][key]
msg = 'You can not use PATH for the set operation: "{}"'
msg = f'You can not use PATH for the set operation: "{key}"'
elif operation == "unset":
msg = "You can not unset PATH"
if msg:
raise ValueError(msg.format(key))
raise ValueError(msg)

def check_min_verbosity(self, config):
"""Return if the given config should be visible based on the resolver's
Expand Down Expand Up @@ -208,9 +241,9 @@ def context(self, context):
# Add the root of this tree to the forest
if self.name in self.forest:
if not isinstance(self.forest[self.name], self._placeholder):
msg = 'Can not add "{}", the context "{}" it is already set'.format(
self.filename,
self.fullpath,
msg = (
f'Can not add "{self.filename}", the context '
f'"{self.fullpath}" it is already set'
)
if self.forest[self.name].root_paths.intersection(self.root_paths):
# If one of the root_paths was already added to target, then
Expand All @@ -231,51 +264,49 @@ def context(self, context):
# Preserve the children of the placeholder object if it exists
self.children = self.forest[self.name].children
self.forest[self.name] = self
logger.debug("Add to forest: {}".format(self))
logger.debug(f"Add to forest: {self}")
else:
resolver = anytree.Resolver("name")
# Get the tree root
root_name = self.context[0]
if root_name in self.forest:
root = self.forest[root_name]
logger.debug("Using root: {}".format(root.fullpath))
logger.debug(f"Using root: {root.fullpath}")
else:
root = self._placeholder(self.forest, self.resolver)
root.name = root_name
self.forest[root_name] = root
logger.debug("Created placeholder root: {}".format(root.fullpath))
logger.debug(f"Created placeholder root: {root.fullpath}")

# Process the intermediate parents
for child_name in self.context[1:]:
try:
root = resolver.get(root, child_name)
logger.debug("Found intermediary: {}".format(root.fullpath))
logger.debug(f"Found intermediary: {root.fullpath}")
except anytree.resolver.ResolverError:
root = self._placeholder(self.forest, self.resolver, parent=root)
root.name = child_name
logger.debug(
"Created placeholder intermediary: {}".format(root.fullpath)
)
logger.debug(f"Created placeholder intermediary: {root.fullpath}")

# Add this node to the tree
try:
target = resolver.get(root, self.name)
except anytree.resolver.ResolverError:
# There is no placeholder, just add self as a child
self.parent = root
logger.debug("Adding to parent: {}".format(root.fullpath))
logger.debug(f"Adding to parent: {root.fullpath}")
else:
if isinstance(target, self._placeholder) and target.name == self.name:
# replace the placeholder with self
self.parent = target.parent
self.children = target.children
# Remove the placeholder from the tree
target.parent = None
logger.debug("Removing placeholder: {}".format(target.fullpath))
logger.debug(f"Removing placeholder: {target.fullpath}")
else:
msg = 'Can not add "{}", the context "{}" it is already set'.format(
self.filename,
self.fullpath,
msg = (
f'Can not add "{self.filename}", the context '
f'"{self.fullpath}" it is already set'
)
if target.root_paths.intersection(self.root_paths):
# If one of the root_paths was already added to target, then
Expand Down Expand Up @@ -670,12 +701,12 @@ def shell_escape(cls, ext, value):
if isinstance(value, list):
value = subprocess.list2cmdline(value)
else:
return '"{}"'.format(value)
return f'"{value}"'
if ext in (".bat", ".cmd"):
if isinstance(value, list):
value = subprocess.list2cmdline(value)
else:
return '"{}"'.format(value)
return f'"{value}"'
return value

@classmethod
Expand Down Expand Up @@ -822,9 +853,9 @@ def generate_config_script(
# At this point we have lost the original double quote the user used.
if isinstance(args, list):
if ext == ".ps1":
args = " {}".format(subprocess.list2cmdline(args))
args = f" {subprocess.list2cmdline(args)}"
else:
args = " {}".format(self.shell_escape(ext, args))
args = f" {self.shell_escape(ext, args)}"
else:
args = ""

Expand Down
Loading