Skip to content

Commit

Permalink
Assert package requirements (#809)
Browse files Browse the repository at this point in the history
* move `_bootstrap_components` to root node. closes #771

* unexplained `MPI.barrier` found

* remove outdated manual bootstrap calls

* cleaned up some more polluting test scaffolds

* add package spec. closes #785

* install `exceptiongroup`

* bump dep for api change

* fix missing import

* improved attribute hints for dict and list

* fix more missing imports
  • Loading branch information
Helveg committed Mar 7, 2024
1 parent 85ec28c commit b053e60
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 64 deletions.
60 changes: 60 additions & 0 deletions bsb/_package_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from exceptiongroup import ExceptionGroup

from bsb.exceptions import PackageRequirementWarning
from bsb.reporting import warn


class MissingRequirementErrors(ExceptionGroup):
pass


def get_missing_requirement_reason(package):
from importlib.metadata import PackageNotFoundError, version

from packaging.requirements import InvalidRequirement, Requirement

try:
req = Requirement(package)
except InvalidRequirement:
return f"Can't check package requirement '{package}': invalid requirement"
try:
ver = version(req.name)
except PackageNotFoundError:
return f"Missing package '{req.name}'. You may experience errors or differences in results."
else:
if not ver in req.specifier:
return (
f"Installed version of '{req.name}' ({ver}) "
f"does not match requirements: '{req}'. You may experience errors or differences in results."
)


def get_missing_packages(packages):
return [
package
for package in packages
if get_missing_requirement_reason(package) is not None
]


def get_unmet_package_reasons(packages):
return [
reason
for package in packages
if (reason := get_missing_requirement_reason(package)) is not None
]


def warn_missing_packages(packages):
for warning in get_unmet_package_reasons(packages):
warn(warning, PackageRequirementWarning)


def raise_missing_packages(packages):
raise MissingRequirementErrors(
"Your model is missing requirement(s)",
[
PackageRequirementWarning(warning)
for warning in get_unmet_package_reasons(packages)
],
)
4 changes: 0 additions & 4 deletions bsb/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,6 @@ def file_has_parser_ext(kv):


def _from_parsed(self, parser_name, tree, meta, file=None):
if "components" in tree:
from ._config import _bootstrap_components

_bootstrap_components(tree["components"])
conf = self.Configuration(tree)
conf._parser = parser_name
conf._meta = meta
Expand Down
22 changes: 21 additions & 1 deletion bsb/config/_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ def _boot_nodes(top_node, scaffold):
run_hook(node, "boot")
except Exception as e:
errr.wrap(BootError, e, prepend=f"Failed to boot {node}:")
# fixme: why is this here? Will deadlock in case of BootError on specific node only.
MPI.barrier()


Expand Down Expand Up @@ -502,9 +503,11 @@ def get_type(self):
return self._config_type

def get_hint(self):
if self.hint is not MISSING:
return self.hint
if hasattr(self.type, "__hint__"):
return self.type.__hint__()
return self.hint
return MISSING

def get_node_name(self, instance):
return instance.get_node_name() + "." + self.attr_name
Expand Down Expand Up @@ -690,6 +693,13 @@ def tree(self, instance):
val = _getattr(instance, self.attr_name)
return [self.tree_of(e) for e in val]

def get_hint(self):
if self.hint is not MISSING:
return self.hint
if hasattr(self.child_type, "__hint__"):
return [self.child_type.__hint__(), self.child_type.__hint__()]
return MISSING


class cfgdict(builtins.dict):
"""
Expand Down Expand Up @@ -834,6 +844,16 @@ def tree(self, instance):
val = _getattr(instance, self.attr_name).items()
return {k: self.tree_of(v) for k, v in val}

def get_hint(self):
if self.hint is not MISSING:
return self.hint
if hasattr(self.child_type, "__hint__"):
return {
"key1": self.child_type.__hint__(),
"key2": self.child_type.__hint__(),
}
return MISSING


class ConfigurationReferenceAttribute(ConfigurationAttribute):
def __init__(
Expand Down
46 changes: 39 additions & 7 deletions bsb/config/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from ._attrs import _boot_nodes, cfgdict, cfglist

if typing.TYPE_CHECKING:
import packaging.requirements

from ..core import Scaffold


Expand Down Expand Up @@ -63,45 +65,82 @@ class Configuration:
components: cfglist[CodeDependencyNode] = config.list(
type=CodeDependencyNode,
)
"""
List of modules relative to the project root containing extension components.
"""
packages: cfglist["packaging.requirements.Requirement"] = config.list(
type=types.PackageRequirement(),
)
"""
List of package requirement specifiers the model depends on.
"""
morphologies: cfglist[MorphologyDependencyNode] = config.list(
type=types.or_(MorphologyDependencyNode, MorphologyPipelineNode),
)
"""
Morphology files and processing pipelines.
"""
storage: StorageNode = config.attr(
type=StorageNode,
required=True,
)
"""
Network storage configuration
"""
network: NetworkNode = config.attr(
type=NetworkNode,
required=True,
)
"""
Network description
"""
regions: cfgdict[str, Region] = config.dict(
type=Region,
)
"""
Network regions
"""
partitions: cfgdict[str, Partition] = config.dict(
type=Partition,
required=True,
)
"""
Network partitions
"""
cell_types: cfgdict[str, CellType] = config.dict(
type=CellType,
required=True,
)
"""
Network cell types
"""
placement: cfgdict[str, PlacementStrategy] = config.dict(
type=PlacementStrategy,
required=True,
)
"""
Network placement strategies
"""
after_placement: cfgdict[str, PostProcessingHook] = config.dict(
type=PostProcessingHook,
)
connectivity: cfgdict[str, ConnectionStrategy] = config.dict(
type=ConnectionStrategy,
required=True,
)
"""
Network connectivity strategies
"""
after_connectivity: cfgdict[str, PostProcessingHook] = config.dict(
type=PostProcessingHook,
)
simulations: cfgdict[str, Simulation] = config.dict(
type=Simulation,
)
"""
Network simulation configuration
"""
__module__ = "bsb.config"

@classmethod
Expand Down Expand Up @@ -150,10 +189,3 @@ def __str__(self):

def __repr__(self):
return f"{type(self).__qualname__}({self})"


def _bootstrap_components(components, file_store=None):
for component in components:
component_node = CodeDependencyNode(component)
component_node.file_store = file_store
component_node.load_object()
21 changes: 20 additions & 1 deletion bsb/config/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

import errr

from .._package_spec import warn_missing_packages
from .._util import get_qualified_class_name
from ..exceptions import (
BootError,
CastError,
ConfigurationError,
DynamicClassError,
Expand Down Expand Up @@ -312,9 +314,17 @@ def __post_new__(self, _parent=None, _key=None, **kwargs):


def wrap_root_postnew(post_new):
def __post_new__(self, *args, _parent=None, _key=None, **kwargs):
def __post_new__(self, *args, _parent=None, _key=None, _store=None, **kwargs):
if not hasattr(self, "_meta"):
self._meta = {"path": None, "produced": True}

try:
# Root node bootstrapping sequence
_bootstrap_components(kwargs.get("components", []), file_store=_store)
warn_missing_packages(kwargs.get("packages", []))
except Exception as e:
raise BootError("Failed to bootstrap configuration.") from e

try:
with warnings.catch_warnings(record=True) as log:
try:
Expand Down Expand Up @@ -353,6 +363,15 @@ def _bubble_up_warnings(log):
warn(str(m), w.category, stacklevel=4)


def _bootstrap_components(components, file_store=None):
from bsb.storage._files import CodeDependencyNode

for component in components:
component_node = CodeDependencyNode(component)
component_node.file_store = file_store
component_node.load_object()


def get_config_attributes(cls):
attrs = {}
if not isinstance(cls, type):
Expand Down
17 changes: 17 additions & 0 deletions bsb/config/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,3 +764,20 @@ def __name__(self):

def __inv__(self, value):
return value.tolist()


class PackageRequirement(TypeHandler):
def __call__(self, value):
from packaging.requirements import Requirement

return Requirement(value)

@property
def __name__(self):
return "package requirement"

def __inv__(self, value):
return str(value)

def __hint__(self):
return "numpy==1.24.0"
18 changes: 1 addition & 17 deletions bsb/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,6 @@ class ConfigurationWarning(ScaffoldWarning):
pass


class UserUserDeprecationWarning(ScaffoldWarning):
pass


class PlacementWarning(ScaffoldWarning):
pass

Expand All @@ -162,17 +158,5 @@ class QuiverFieldWarning(ScaffoldWarning):
pass


class RepositoryWarning(ScaffoldWarning):
pass


class SimulationWarning(ScaffoldWarning):
pass


class KernelWarning(SimulationWarning):
pass


class CriticalDataWarning(ScaffoldWarning):
class PackageRequirementWarning(ScaffoldWarning):
pass
4 changes: 1 addition & 3 deletions bsb/storage/fs/file_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,14 @@ def load_active_config(self):
:raises Exception: When there's no active configuration in the file store.
"""
from bsb.config import Configuration
from bsb.config._config import _bootstrap_components

stored = self.find_meta("active_config", True)
if stored is None:
raise Exception("No active config")
else:
content, meta = stored.load()
tree = json.loads(content)
_bootstrap_components(tree.get("components", []), self)
cfg = Configuration(**tree)
cfg = Configuration(**tree, _store=self)
cfg._meta = meta
return cfg

Expand Down
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@

intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"packaging": ("https://packaging.pypa.io/en/stable/", None),
"numpy": ("https://numpy.org/doc/stable/", None),
"scipy": ("https://scipy.github.io/devdocs/", None),
"errr": ("https://errr.readthedocs.io/en/latest/", None),
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
"tqdm~=4.50",
"shortuuid",
"quantities",
"exceptiongroup",
]

[tool.flit.module]
Expand Down Expand Up @@ -65,7 +66,7 @@ parallel = [
]
test = [
"bsb-arbor==0.0.0b1",
"bsb-hdf5==1.0.0b1",
"bsb-hdf5==1.0.0b2",
"bsb-test==0.0.0b14",
"coverage~=7.3",
]
Expand Down

0 comments on commit b053e60

Please sign in to comment.