From fbc12df7d9140634a9a6293c5b14c19e99daa511 Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Mon, 22 Jul 2024 15:20:43 +0200 Subject: [PATCH 01/12] Log a warning when an executable is found, but does not have the exec bit set --- pyiron_snippets/resources.py | 3 +++ tests/unit/test_resources.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index cc17918..bb709b2 100644 --- a/pyiron_snippets/resources.py +++ b/pyiron_snippets/resources.py @@ -256,6 +256,9 @@ def cond(path): isexec = os.access( path, os.X_OK, effective_ids=os.access in os.supports_effective_ids ) + if isfile and not isexec: + from pyiron_snippets.logger import logger + logger.warning(f"Found file '{path}', but skipping it because it is not executable!") return isfile and isexec for path in filter(cond, self._resolver.search(self._glob)): diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index bbee285..1f99251 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -2,6 +2,7 @@ import os.path import unittest from pyiron_snippets.resources import ResourceNotFound, ResourceResolver, ExecutableResolver +from pyiron_snippets.logger import logger class TestResolvers(unittest.TestCase): """ @@ -55,6 +56,8 @@ 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) + with self.assertLogs(logger, level="WARNING"): + res.list() if os.name != "nt": # no exec bits are present on windows it seems self.assertNotIn("versionnonexec", res.available_versions, From 9aa5e752965b9ae9eba1df10469065a2e9872af8 Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Mon, 22 Jul 2024 15:45:47 +0200 Subject: [PATCH 02/12] Skip test on windows --- tests/unit/test_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index 1f99251..b2da759 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -56,9 +56,9 @@ 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) - with self.assertLogs(logger, level="WARNING"): - res.list() if os.name != "nt": + with self.assertLogs(logger, level="WARNING"): + res.list() # no exec bits are present on windows it seems self.assertNotIn("versionnonexec", res.available_versions, "ExecutableResolver must not list scripts that are not executable.") From 039f62f0022077dbd546fd4355bd0f869ba2d2a8 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Mon, 22 Jul 2024 13:51:48 +0000 Subject: [PATCH 03/12] Format black --- pyiron_snippets/resources.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index bb709b2..3972e2a 100644 --- a/pyiron_snippets/resources.py +++ b/pyiron_snippets/resources.py @@ -258,7 +258,10 @@ def cond(path): ) if isfile and not isexec: from pyiron_snippets.logger import logger - logger.warning(f"Found file '{path}', but skipping it because it is not executable!") + + logger.warning( + f"Found file '{path}', but skipping it because it is not executable!" + ) return isfile and isexec for path in filter(cond, self._resolver.search(self._glob)): From caa505a7849a148da77f97c0219701f8c414891f Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Mon, 22 Jul 2024 09:41:44 -0700 Subject: [PATCH 04/12] [patch] Import annotations for 3.9 compatibility with type hint sugar (#24) --- pyiron_snippets/resources.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index cc17918..5a6e3e8 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 From 982faaf7bc71e456961cce71ec4632a8ba3f845e Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Tue, 23 Jul 2024 12:10:34 +0200 Subject: [PATCH 05/12] Add comments for windows --- pyiron_snippets/resources.py | 4 ++++ tests/unit/test_resources.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index 3972e2a..ae6efe6 100644 --- a/pyiron_snippets/resources.py +++ b/pyiron_snippets/resources.py @@ -202,6 +202,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,6 +256,7 @@ 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 ) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index b2da759..de1fda7 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -56,10 +56,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": with self.assertLogs(logger, level="WARNING"): res.list() - # no exec bits are present on windows it seems self.assertNotIn("versionnonexec", res.available_versions, "ExecutableResolver must not list scripts that are not executable.") self.assertNotIn("wrong_format", res.available_versions, From 4d267569345f3f04b84a3d2d0a5b9b6cb6acd8b6 Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Tue, 23 Jul 2024 13:50:45 -0700 Subject: [PATCH 06/12] Update CI target to 3.2.1 (#25) --- .github/workflows/daily.yml | 2 +- .github/workflows/dependabot-pr.yml | 2 +- .github/workflows/pr-labeled.yml | 2 +- .github/workflows/pr-target-opened.yml | 2 +- .github/workflows/push-pull.yml | 2 +- .github/workflows/release-or-preview.yml | 2 +- .github/workflows/weekly.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) 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 From 6fd7ac81deeba1bdf017c6be155ce5be2786ca2e Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Wed, 24 Jul 2024 11:55:37 +0200 Subject: [PATCH 07/12] Use a warning instead of logger Avoids creating random files for users who otherwise do not use the logger. --- pyiron_snippets/resources.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index ae6efe6..8fe1001 100644 --- a/pyiron_snippets/resources.py +++ b/pyiron_snippets/resources.py @@ -10,6 +10,7 @@ from glob import glob import re from typing import Any +import warnings if os.name == "nt": EXE_SUFFIX = "bat" @@ -20,6 +21,8 @@ class ResourceNotFound(RuntimeError): pass +class ResolverWarning(RuntimeWarning): + pass class AbstractResolver(ABC): """ @@ -261,10 +264,12 @@ def cond(path): path, os.X_OK, effective_ids=os.access in os.supports_effective_ids ) if isfile and not isexec: - from pyiron_snippets.logger import logger - - logger.warning( - f"Found file '{path}', but skipping it because it is not executable!" + 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 From 87d7912025b37683a37a8a59d42234b4ddd08a0c Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Wed, 24 Jul 2024 13:22:58 +0200 Subject: [PATCH 08/12] Adapt test to warning --- tests/unit/test_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index de1fda7..c822bc5 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 from pyiron_snippets.logger import logger class TestResolvers(unittest.TestCase): @@ -58,7 +58,7 @@ def test_executable(self): 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": - with self.assertLogs(logger, level="WARNING"): + with self.assertWarns(ResolverWarning): res.list() self.assertNotIn("versionnonexec", res.available_versions, "ExecutableResolver must not list scripts that are not executable.") From dd06a761bf236c48d638f1dbda43b89939d85978 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 24 Jul 2024 11:35:11 +0000 Subject: [PATCH 09/12] Format black --- pyiron_snippets/resources.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyiron_snippets/resources.py b/pyiron_snippets/resources.py index 8fe1001..c640051 100644 --- a/pyiron_snippets/resources.py +++ b/pyiron_snippets/resources.py @@ -21,9 +21,11 @@ class ResourceNotFound(RuntimeError): pass + class ResolverWarning(RuntimeWarning): pass + class AbstractResolver(ABC): """ Interface for resolvers. From efd976ce2b9349ce0bfc591afe0a3c37e898b7b5 Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Wed, 24 Jul 2024 16:48:16 +0200 Subject: [PATCH 10/12] Drop unused logger import in tests --- tests/unit/test_resources.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index c822bc5..88287eb 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -2,7 +2,6 @@ import os.path import unittest from pyiron_snippets.resources import ResourceNotFound, ResourceResolver, ExecutableResolver, ResolverWarning -from pyiron_snippets.logger import logger class TestResolvers(unittest.TestCase): """ From 5c2f87aaa03acd0a349743b5d9801e7d2f2140a4 Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Mon, 29 Jul 2024 22:41:54 -0700 Subject: [PATCH 11/12] [patch] Fix readme example formatting (#26) I don't know what the hell sort of linter madness got a hold of these examples, but they got broken so badly they weren't even being recognized by the test suite as examples so they wouldn't even fail the tests! --- docs/README.md | 372 +++++++++++++++++-------------------------------- 1 file changed, 127 insertions(+), 245 deletions(-) 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 From e0beaa6f54ad1d677e1f46ea05901fc09642195e Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Wed, 7 Aug 2024 20:42:40 -0700 Subject: [PATCH 12/12] [patch] Allow `_FactoryMade` to explicitly define where its reduce should import from (#27) * Allow `_FactoryMade` to explicitly define where its reduce should import from * Give the same protection with the new syntax * Add comment * Extend tests to cover both syntaxes * Edit docstring * Format black --------- Co-authored-by: pyiron-runner --- pyiron_snippets/factory.py | 29 ++++++++++++++---- tests/unit/test_factory.py | 61 ++++++++++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 21 deletions(-) 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/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)