diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index ddc05eb..aa397f7 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -8,7 +8,7 @@ on: jobs: codeql: - uses: pyiron/actions/.github/workflows/tests-and-coverage.yml@actions-3.1.0 + uses: pyiron/actions/.github/workflows/tests-and-coverage.yml@actions-3.2.1 secrets: inherit with: tests-env-files: .ci_support/environment.yml .ci_support/environment-tests.yml \ No newline at end of file diff --git a/.github/workflows/dependabot-pr.yml b/.github/workflows/dependabot-pr.yml index 0559d31..11d7709 100644 --- a/.github/workflows/dependabot-pr.yml +++ b/.github/workflows/dependabot-pr.yml @@ -6,5 +6,5 @@ on: jobs: pyiron: - uses: pyiron/actions/.github/workflows/dependabot-pr.yml@actions-3.1.0 + uses: pyiron/actions/.github/workflows/dependabot-pr.yml@actions-3.2.1 secrets: inherit \ No newline at end of file diff --git a/.github/workflows/pr-labeled.yml b/.github/workflows/pr-labeled.yml index 7881d90..fac7e5a 100644 --- a/.github/workflows/pr-labeled.yml +++ b/.github/workflows/pr-labeled.yml @@ -8,5 +8,5 @@ on: jobs: pyiron: - uses: pyiron/actions/.github/workflows/pr-labeled.yml@actions-3.1.0 + uses: pyiron/actions/.github/workflows/pr-labeled.yml@actions-3.2.1 secrets: inherit \ No newline at end of file diff --git a/.github/workflows/pr-target-opened.yml b/.github/workflows/pr-target-opened.yml index 48b14f1..434c040 100644 --- a/.github/workflows/pr-target-opened.yml +++ b/.github/workflows/pr-target-opened.yml @@ -8,5 +8,5 @@ on: jobs: pyiron: - uses: pyiron/actions/.github/workflows/pr-target-opened.yml@actions-2.0.7 + uses: pyiron/actions/.github/workflows/pr-target-opened.yml@actions-3.2.1 secrets: inherit \ No newline at end of file diff --git a/.github/workflows/push-pull.yml b/.github/workflows/push-pull.yml index f26c046..914771c 100644 --- a/.github/workflows/push-pull.yml +++ b/.github/workflows/push-pull.yml @@ -9,7 +9,7 @@ on: jobs: pyiron: - uses: pyiron/actions/.github/workflows/push-pull.yml@actions-3.1.0 + uses: pyiron/actions/.github/workflows/push-pull.yml@actions-3.2.1 secrets: inherit with: tests-env-files: .ci_support/environment.yml .ci_support/environment-tests.yml diff --git a/.github/workflows/release-or-preview.yml b/.github/workflows/release-or-preview.yml index b465bda..27129ae 100644 --- a/.github/workflows/release-or-preview.yml +++ b/.github/workflows/release-or-preview.yml @@ -10,7 +10,7 @@ on: jobs: pyproject-flow: - uses: pyiron/actions/.github/workflows/pyproject-release.yml@actions-3.1.0 + uses: pyiron/actions/.github/workflows/pyproject-release.yml@actions-3.2.1 secrets: inherit with: semantic-upper-bound: 'minor' diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 66f744a..2642d48 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -8,5 +8,5 @@ on: jobs: codeql: - uses: pyiron/actions/.github/workflows/codeql.yml@actions-3.1.0 + uses: pyiron/actions/.github/workflows/codeql.yml@actions-3.2.1 secrets: inherit \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index a8a8d38..108ea18 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,8 +27,8 @@ The snippets may have more functionality that this -- taking a look at the test Just a shortcut to the `seaborn.color_palette()` of colors in hex: ```python ->> > from pyiron_snippets.colors import SeabornColors ->> > SeabornColors.white +>>> from pyiron_snippets.colors import SeabornColors +>>> SeabornColors.white '#ffffff' ``` @@ -38,22 +38,13 @@ Just a shortcut to the `seaborn.color_palette()` of colors in hex: Easily indicate that some functionality is being deprecated ```python ->> > from pyiron_snippets.deprecate import deprecate ->> > ->> > - -@deprecate(message="Use `bar(a, b)` instead", version="0.5.0") - - -... - - -def foo(a, b): - - - ... -pass ->> > foo(1, 2) +>>> from pyiron_snippets.deprecate import deprecate +>>> +>>> @deprecate(message="Use `bar(a, b)` instead", version="0.5.0") +... def foo(a, b): +... pass +>>> +>>> foo(1, 2) ``` @@ -65,13 +56,12 @@ Raises a warning like `DeprecationWarning: __main__.foo is deprecated: Use bar(a A dictionary that allows dot-access. Has `.items()` etc. ```python ->> > from pyiron_snippets.dotdict import DotDict ->> > ->> > d = DotDict({"a": 1}) ->> > d.b = 2 ->> > print(d.a, d.b) -1 -2 +>>> from pyiron_snippets.dotdict import DotDict +>>> +>>> d = DotDict({"a": 1}) +>>> d.b = 2 +>>> print(d.a, d.b) +1 2 ``` @@ -80,76 +70,40 @@ A dictionary that allows dot-access. Has `.items()` etc. Make dynamic classes that are still pickle-able ```python ->> > from abc import ABC ->> > import pickle ->> > ->> > from pyiron_snippets.factory import classfactory ->> > ->> > - -class HasN(ABC): - - - ... -'''Some class I want to make dynamically subclass.''' -... - - -def __init_subclass__(cls, /, n=0, s="foo", **kwargs): - - - ... -super(HasN, cls).__init_subclass__(**kwargs) -... -cls.n = n -... -cls.s = s -... -... - - -def __init__(self, x, y=0): - - - ... -self.x = x -... -self.y = y ->> > ->> > - -@classfactory - - -... - - -def has_n_factory(n, s="wrapped_function", /): - - - ... -return ( - ... f"{HasN.__name__}{n}{s}", # New class name -...(HasN, ), # Base class(es) -... -{}, # Class attributes dictionary -... -{"n": n, "s": s} +>>> from abc import ABC +>>> import pickle +>>> +>>> from pyiron_snippets.factory import classfactory +>>> +>>> class HasN(ABC): +... '''Some class I want to make dynamically subclass.''' +... def __init_subclass__(cls, /, n=0, s="foo", **kwargs): +... super(HasN, cls).__init_subclass__(**kwargs) +... cls.n = n +... cls.s = s +... +... def __init__(self, x, y=0): +... self.x = x +... self.y = y +>>> +>>> @classfactory +... def has_n_factory(n, s="wrapped_function", /): +... return ( +... f"{HasN.__name__}{n}{s}", # New class name +... (HasN, ), # Base class(es) +... {}, # Class attributes dictionary +... {"n": n, "s": s} ... # dict of `builtins.type` kwargs (passed to `__init_subclass__`) ... ) ->> > Has2 = has_n_factory(2, "my_dynamic_class") ->> > ->> > foo = Has2(42, y=-1) ->> > print(foo.n, foo.s, foo.x, foo.y) -2 -my_dynamic_class -42 - 1 - ->> > reloaded = pickle.loads(pickle.dumps(foo)) # doctest: +SKIP ->> > print(reloaded.n, reloaded.s, reloaded.x, reloaded.y) # doctest: +SKIP -2 -my_dynamic_class -42 - 1 # doctest: +SKIP +>>> +>>> Has2 = has_n_factory(2, "my_dynamic_class") +>>> +>>> foo = Has2(42, y=-1) +>>> print(foo.n, foo.s, foo.x, foo.y) +2 my_dynamic_class 42 -1 +>>> reloaded = pickle.loads(pickle.dumps(foo)) # doctest: +SKIP +>>> print(reloaded.n, reloaded.s, reloaded.x, reloaded.y) # doctest: +SKIP +2 my_dynamic_class 42 -1 # doctest: +SKIP ``` @@ -161,19 +115,19 @@ my_dynamic_class Shortcuts for filesystem manipulation ```python ->> > from pyiron_snippets.files import DirectoryObject, FileObject ->> > ->> > d = DirectoryObject("some_dir") ->> > d.write(file_name="my_filename.txt", content="Some content") ->> > f = FileObject("my_filename.txt", directory=d) ->> > f.is_file() +>>> from pyiron_snippets.files import DirectoryObject, FileObject +>>> +>>> d = DirectoryObject("some_dir") +>>> d.write(file_name="my_filename.txt", content="Some content") +>>> f = FileObject("my_filename.txt", directory=d) +>>> f.is_file() True ->> > f2 = f.copy("new_filename.txt", directory=d.create_subdirectory("sub")) ->> > f2.read() +>>> f2 = f.copy("new_filename.txt", directory=d.create_subdirectory("sub")) +>>> f2.read() 'Some content' ->> > d.file_exists("sub/new_filename.txt") +>>> d.file_exists("sub/new_filename.txt") True ->> > d.delete() +>>> d.delete() ``` @@ -183,55 +137,24 @@ True A meta-class introducing a `__post__` dunder which runs after the `__init__` of _everything_ in the MRO. ```python ->> > from pyiron_snippets.has_post import HasPost ->> > ->> > - -class Foo(metaclass=HasPost): - - - ... - - -def __init__(self, x=0): - - - ... -self.x = x -... -print(f"Foo.__init__: x = {self.x}") ->> > ->> > - -class Bar(Foo): - - - ... - - -def __init__(self, x=0, post_extra=2): - - - ... -super().__init__(x) -... -self.x += 1 -... -print(f"Bar.__init__: x = {self.x}") -... -... - - -def __post__(self, *args, post_extra=2, **kwargs): - - - ... -self.x += post_extra -... -... -print(f"Bar.__post__: x = {self.x}") ->> > ->> > Bar().x +>>> from pyiron_snippets.has_post import HasPost +>>> +>>> class Foo(metaclass=HasPost): +... def __init__(self, x=0): +... self.x = x +... print(f"Foo.__init__: x = {self.x}") +>>> +>>> class Bar(Foo): +... def __init__(self, x=0, post_extra=2): +... super().__init__(x) +... self.x += 1 +... print(f"Bar.__init__: x = {self.x}") +... +... def __post__(self, *args, post_extra=2, **kwargs): +... self.x += post_extra +... print(f"Bar.__post__: x = {self.x}") +>>> +>>> Bar().x Foo.__init__: x = 0 Bar.__init__: x = 1 Bar.__post__: x = 3 @@ -246,68 +169,40 @@ Honestly, try thinking if there's another way to solve your problem; this is a d Fail gracefully when optional dependencies are missing for (optional) functionality. ```python ->> > from pyiron_snippets.import_alarm import ImportAlarm ->> > ->> > with ImportAlarm( - ... "Some functionality unavailable: `magic` dependency missing" -...) as my_magic_alarm: - ... -import magic ->> > ->> > with ImportAlarm("This warning won't show up") as datetime_alarm: - ... -import datetime ->> > ->> > - -class Foo: - - - ... @ my_magic_alarm -... @ datetime_alarm -... - - -def __init__(self, x): - - - ... -self.x = x -... -... @ property -... - - -def magical(self): - - - ... -return magic.method(self.x) -... -... - - -def a_space_odyssey(self): - - - ... -print(datetime.date(2001, 1, 1)) -... ->> > ->> > foo = Foo(0) ->> > # Raises a warning re `magic` (since that does not exist) ->> > # but not re `datetime` (since it does and we certainly have it) ->> > foo.a_space_odyssey() -2001 - 01 - 01 - ->> > try: - ... -foo.magical(0) +>>> from pyiron_snippets.import_alarm import ImportAlarm +>>> +>>> with ImportAlarm( +... "Some functionality unavailable: `magic` dependency missing" +... ) as my_magic_alarm: +... import magic +>>> +>>> with ImportAlarm("This warning won't show up") as datetime_alarm: +... import datetime +>>> +>>> class Foo: +... @my_magic_alarm +... @datetime_alarm +... def __init__(self, x): +... self.x = x +... +... @property +... def magical(self): +... return magic.method(self.x) +... +... def a_space_odyssey(self): +... print(datetime.date(2001, 1, 1)) +>>> +>>> foo = Foo(0) +>>> # Raises a warning re `magic` (since that does not exist) +>>> # but not re `datetime` (since it does and we certainly have it) +>>> foo.a_space_odyssey() +2001-01-01 + +>>> try: +... foo.magical(0) ... except NameError as e: -... -print("ERROR:", e) -ERROR: name -'magic' is not defined +... print("ERROR:", e) +ERROR: name 'magic' is not defined ``` @@ -320,25 +215,17 @@ Configures the logger and writes to `pyiron.log` If at first you don't succeed ```python ->> > from time import time ->> > ->> > from pyiron_snippets.retry import retry ->> > ->> > - -def at_most_three_seconds(): - - - ... -t = int(time()) -... -if t % 3 != 0: - ... -raise ValueError("Not yet!") -... -return t ->> > ->> > retry(at_most_three_seconds, msg="Tried and failed...", error=ValueError) % 3 +>>> from time import time +>>> +>>> from pyiron_snippets.retry import retry +>>> +>>> def at_most_three_seconds(): +... t = int(time()) +... if t % 3 != 0: +... raise ValueError("Not yet!") +... return t +>>> +>>> retry(at_most_three_seconds, msg="Tried and failed...", error=ValueError) % 3 0 ``` @@ -351,19 +238,14 @@ Depending on the system clock at invokation, this simple example may give warnin A metaclass for the [singleton pattern](https://en.wikipedia.org/wiki/Singleton_pattern). ```python ->> > from pyiron_snippets.singleton import Singleton ->> > ->> > - -class Foo(metaclass=Singleton): - - - ... -pass ->> > ->> > foo1 = Foo() ->> > foo2 = Foo() ->> > foo1 is foo2 +>>> from pyiron_snippets.singleton import Singleton +>>> +>>> class Foo(metaclass=Singleton): +... pass +>>> +>>> foo1 = Foo() +>>> foo2 = Foo() +>>> foo1 is foo2 True ``` \ No newline at end of file diff --git a/pyiron_snippets/factory.py b/pyiron_snippets/factory.py index 98d5caf..f5ce2fc 100644 --- a/pyiron_snippets/factory.py +++ b/pyiron_snippets/factory.py @@ -237,16 +237,22 @@ class _FactoryMade(ABC): """ A mix-in to make class-factory-produced classes pickleable. - If the factory is used as a decorator for another function, it will conflict with - this function (i.e. the owned function will be the true function, and will mismatch - with imports from that location, which will return the post-decorator factory made - class). This can be resolved by setting the + If the factory is used as a decorator for another function (or class), it will + conflict with this function (i.e. the owned function will be the true function, + and will mismatch with imports from that location, which will return the + post-decorator factory made class). This can be resolved by setting the + :attr:`_reduce_imports_as` attribute to a tuple of the (module, qualname) obtained + from the decorated definition in order to manually specify where it should be + re-imported from. (DEPRECATED alternative: set :attr:`_class_returns_from_decorated_function` attribute to be the decorated - function in the decorator definition. + function in the decorator definition.) """ + # DEPRECATED: Use _reduce_imports_as instead _class_returns_from_decorated_function: ClassVar[callable | None] = None + _reduce_imports_as: ClassVar[tuple[str, str] | None] = None # Module and qualname + def __init_subclass__(cls, /, class_factory, class_factory_args, **kwargs): super().__init_subclass__(**kwargs) cls._class_factory = class_factory @@ -271,6 +277,19 @@ def __reduce__(self): ), self.__getstate__(), ) + elif ( + self._reduce_imports_as is not None + and "" not in self._reduce_imports_as[1] + ): + return ( + _instantiate_from_decorated, + ( + self._reduce_imports_as[0], + self._reduce_imports_as[1], + self.__getnewargs_ex__(), + ), + self.__getstate__(), + ) else: return ( _instantiate_from_factory, diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index cc17918..995f33d 100644 --- a/pyiron_snippets/resources.py +++ b/pyiron_snippets/resources.py @@ -2,6 +2,8 @@ Classes to find data files and executables in global paths. """ +from __future__ import annotations + from abc import ABC, abstractmethod from collections.abc import Iterator, Iterable import os @@ -10,6 +12,7 @@ from glob import glob import re from typing import Any +import warnings if os.name == "nt": EXE_SUFFIX = "bat" @@ -21,6 +24,10 @@ class ResourceNotFound(RuntimeError): pass +class ResolverWarning(RuntimeWarning): + pass + + class AbstractResolver(ABC): """ Interface for resolvers. @@ -202,6 +209,9 @@ class ExecutableResolver(AbstractResolver): and have the executable bit set. :meth:`.search` yields tuples of version strings and full paths to the executable instead of plain strings. + Except on windows results are filtered to make sure all returned scripts have the executable bit set. + When the bit is not set, a warning is printed. + >>> exe = ExecutableResolver(..., "lammps") >>> exe.list() # doctest: +SKIP [ @@ -253,9 +263,18 @@ def _search(self, name): def cond(path): isfile = os.path.isfile(path) + # HINT: this is always True on windows isexec = os.access( path, os.X_OK, effective_ids=os.access in os.supports_effective_ids ) + if isfile and not isexec: + warnings.warn( + f"Found file '{path}', but skipping it because it is not executable!", + category=ResolverWarning, + # TODO: maybe used from python3.12 onwards + # skip_file_prefixes=(os.path.dirname(__file__),), + stacklevel=4, + ) return isfile and isexec for path in filter(cond, self._resolver.search(self._glob)): diff --git a/tests/unit/test_factory.py b/tests/unit/test_factory.py index 8405c95..baca521 100644 --- a/tests/unit/test_factory.py +++ b/tests/unit/test_factory.py @@ -116,6 +116,20 @@ def add_to_function(self, *args, **kwargs): @classfactory def adder_factory(fnc, n, /): + return ( + f"{AddsNandX.__name__}{fnc.__name__}", + (AddsNandX,), + { + "fnc": staticmethod(fnc), + "n": n, + "_reduce_imports_as": (fnc.__module__, fnc.__qualname__) + }, + {}, + ) + + +@classfactory +def deprecated_adder_factory(fnc, n, /): return ( f"{AddsNandX.__name__}{fnc.__name__}", (AddsNandX,), @@ -131,7 +145,7 @@ def adder_factory(fnc, n, /): def add_to_this_decorator(n): def wrapped(fnc): factory_made = adder_factory(fnc, n) - factory_made._class_returns_from_decorated_function = fnc + factory_made._reduce_imports_as = (fnc.__module__, fnc.__qualname__) return factory_made return wrapped @@ -141,6 +155,19 @@ def adds_5_plus_x(y: int): return y +def deprecated_add_to_this_decorator(n): + def wrapped(fnc): + factory_made = adder_factory(fnc, n) + factory_made._class_returns_from_decorated_function = fnc + return factory_made + return wrapped + + +@deprecated_add_to_this_decorator(5) +def deprecated_adds_5_plus_x(y: int): + return y + + class TestClassfactory(unittest.TestCase): def test_factory_initialization(self): @@ -474,21 +501,23 @@ def test_other_decorators(self): In case the factory-produced class itself comes from a decorator, we need to check that name conflicts between the class and decorated function are handled. """ - a5 = adds_5_plus_x(2) - self.assertIsInstance(a5, AddsNandX) - self.assertIsInstance(a5, _FactoryMade) - self.assertEqual(5, a5.n) - self.assertEqual(2, a5.x) - self.assertEqual( - 1 + 5 + 2, # y + n=5 + x=2 - a5.add_to_function(1), - msg="Should execute the function as part of call" - ) - - reloaded = pickle.loads(pickle.dumps(a5)) - self.assertEqual(a5.n, reloaded.n) - self.assertIs(a5.fnc, reloaded.fnc) - self.assertEqual(a5.x, reloaded.x) + for fnc in [adds_5_plus_x, deprecated_adds_5_plus_x]: + with self.subTest(fnc.__name__): + a5 = fnc(2) + self.assertIsInstance(a5, AddsNandX) + self.assertIsInstance(a5, _FactoryMade) + self.assertEqual(5, a5.n) + self.assertEqual(2, a5.x) + self.assertEqual( + 1 + 5 + 2, # y + n=5 + x=2 + a5.add_to_function(1), + msg="Should execute the function as part of call" + ) + + reloaded = pickle.loads(pickle.dumps(a5)) + self.assertEqual(a5.n, reloaded.n) + self.assertIs(a5.fnc, reloaded.fnc) + self.assertEqual(a5.x, reloaded.x) def test_other_decorators_inside_locals(self): @add_to_this_decorator(6) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index bbee285..88287eb 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -1,7 +1,7 @@ import os import os.path import unittest -from pyiron_snippets.resources import ResourceNotFound, ResourceResolver, ExecutableResolver +from pyiron_snippets.resources import ResourceNotFound, ResourceResolver, ExecutableResolver, ResolverWarning class TestResolvers(unittest.TestCase): """ @@ -55,8 +55,10 @@ def test_executable(self): for suffix in (None, "sh", "bat"): with self.subTest(suffix=suffix): res = ExecutableResolver([self.res1], code="code1", module="module1", suffix=suffix) + # Windows always reports the exec bit as set, so skip those tests there if os.name != "nt": - # no exec bits are present on windows it seems + with self.assertWarns(ResolverWarning): + res.list() self.assertNotIn("versionnonexec", res.available_versions, "ExecutableResolver must not list scripts that are not executable.") self.assertNotIn("wrong_format", res.available_versions,