From 476da04e2de16448d0d0aa7d0f7db0af4c19359f Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:33:14 +0100 Subject: [PATCH 01/17] add SimpleResolver --- src/class_resolver/base.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index df07eec..f876aaf 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -312,3 +312,46 @@ def objective(trial: optuna.Trial) -> float: """ key = trial.suggest_categorical(name, sorted(self.lookup_dict)) return self.lookup(key) + + +class SimpleResolver(BaseResolver[X, X]): + """ + A simple resolver which uses the string representations as key. + + While very minimalistic, it can be quite handy when dealing with simple objects, e.g., + + >>> r = SimpleResolver([0, 1, 2, 3], default=0) + >>> r.make(None) + 0 + >>> r.make(3) + 3 + >>> r.make(7) + Traceback (most recent call last): + ... + ValueError: Invalid query=7. Possible queries are {0, 1, 2, 3}. + """ + + # docstr-coverage: inherited + def extract_name(self, element: X) -> str: # noqa: D102 + return str(element) + + # docstr-coverage: inherited + def lookup(self, query: Hint[X], default: Optional[X] = None) -> X: # noqa: D102 + str_query = str(query) + if str_query in self.lookup_dict: + return self.lookup_dict[str_query] + if query is not None: + raise ValueError(f"Invalid query={query}. Possible queries are {self.options}.") + if default is not None: + return default + if self.default is not None: + return self.default + raise ValueError( + f"If query and default are None, a default must be set in the resolver, but it is None, too." + ) + + # docstr-coverage: inherited + def make(self, query, pos_kwargs: OptionalKwargs = None, **kwargs) -> X: # noqa: D102 + if pos_kwargs is not None: + raise ValueError(f"{self.__class__.__name__} does not support positional arguments.") + return self.lookup(query=query, **kwargs) From f3c567407e77e9bab2b39702c7ccc2f96d2370f4 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:33:52 +0100 Subject: [PATCH 02/17] expose SimpleResolver --- src/class_resolver/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/class_resolver/__init__.py b/src/class_resolver/__init__.py index f527c02..8d48e16 100644 --- a/src/class_resolver/__init__.py +++ b/src/class_resolver/__init__.py @@ -54,6 +54,7 @@ class is ``Algorithm`` and it can infer what you mean. ) from .base import ( BaseResolver, + SimpleResolver, RegistrationError, RegistrationNameConflict, RegistrationSynonymConflict, @@ -93,6 +94,7 @@ class is ``Algorithm`` and it can infer what you mean. "Resolver", "ClassResolver", "FunctionResolver", + "SimpleResolver", # Utilities "get_cls", "get_subclasses", From 713eed09451292a618e6e6dd047916a828871019 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:36:33 +0100 Subject: [PATCH 03/17] fix import order --- src/class_resolver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/class_resolver/__init__.py b/src/class_resolver/__init__.py index 8d48e16..1aef313 100644 --- a/src/class_resolver/__init__.py +++ b/src/class_resolver/__init__.py @@ -54,10 +54,10 @@ class is ``Algorithm`` and it can infer what you mean. ) from .base import ( BaseResolver, - SimpleResolver, RegistrationError, RegistrationNameConflict, RegistrationSynonymConflict, + SimpleResolver, ) from .func import FunctionResolver from .utils import ( From 92cc8529df34b9b7a2bdcb723bad310845c276f5 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:36:53 +0100 Subject: [PATCH 04/17] fix empty f-string --- src/class_resolver/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index f876aaf..8a7f7c0 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -347,7 +347,7 @@ def lookup(self, query: Hint[X], default: Optional[X] = None) -> X: # noqa: D10 if self.default is not None: return self.default raise ValueError( - f"If query and default are None, a default must be set in the resolver, but it is None, too." + "If query and default are None, a default must be set in the resolver, but it is None, too." ) # docstr-coverage: inherited From 9b4ded896f6160bc897b35d71ec3bebe76ba65b0 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:40:43 +0100 Subject: [PATCH 05/17] repeat doctest in test_api --- tests/test_api.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index abe65c8..42db94b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,6 +9,7 @@ import click from click.testing import CliRunner, Result from docdata import parse_docdata +import pytest from class_resolver import ( VERSION, @@ -17,6 +18,7 @@ RegistrationNameConflict, RegistrationSynonymConflict, Resolver, + SimpleResolver, UnexpectedKeywordError, ) @@ -490,3 +492,18 @@ class AAlt3Base(Alt3Base): with self.assertRaises(TypeError) as e: resolver.make("a") self.assertEqual("surprise!", str(e.exception)) + + +def test_simple_resolver(): + """Test simple resolver.""" + sr = SimpleResolver([0, 1, 2, 3]) + for i in range(4): + assert sr.make(i) == i + assert sr.make(str(i)) == i + with pytest.raises(ValueError): + sr.make(-1) + with pytest.raises(ValueError): + sr.make(4) + with pytest.raises(ValueError): + sr.make(None) + assert sr.make(None, default=2) == 2 From 967f2858b7d9356a63f27f04aa70ae5d718e2994 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:43:53 +0100 Subject: [PATCH 06/17] add shorthand for typing.Literal --- src/class_resolver/base.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index 8a7f7c0..09930fb 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -3,6 +3,7 @@ """A base resolver.""" import logging +import typing from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, @@ -11,12 +12,14 @@ Generic, Iterable, Iterator, + Literal, Mapping, Optional, Set, ) from pkg_resources import iter_entry_points +from typing_extensions import Self from .utils import Hint, OptionalKwargs, X, Y, make_callback, normalize_string @@ -337,7 +340,7 @@ def extract_name(self, element: X) -> str: # noqa: D102 # docstr-coverage: inherited def lookup(self, query: Hint[X], default: Optional[X] = None) -> X: # noqa: D102 - str_query = str(query) + str_query = self.normalize(str(query)) if str_query in self.lookup_dict: return self.lookup_dict[str_query] if query is not None: @@ -355,3 +358,18 @@ def make(self, query, pos_kwargs: OptionalKwargs = None, **kwargs) -> X: # noqa if pos_kwargs is not None: raise ValueError(f"{self.__class__.__name__} does not support positional arguments.") return self.lookup(query=query, **kwargs) + + @classmethod + def from_literal(cls, literal: Literal, **kwargs) -> Self: + """ + Construct a simple resolver for the given `typing.Literal`. + + :param literal: + the type annotation for literals. + :param kwargs: + additional keyword-based parameters passed to :meth:`__init__` + + :return: + a simple resolver for the literal values. + """ + return cls(elements=typing.get_args(literal), **kwargs) From 6e41dbbdc61b0f0c4569c29f5e3e7a3448b92326 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:48:24 +0100 Subject: [PATCH 07/17] remove type annotation --- src/class_resolver/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index 09930fb..1c6f99a 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -7,12 +7,12 @@ from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Any, Collection, Dict, Generic, Iterable, Iterator, - Literal, Mapping, Optional, Set, @@ -360,7 +360,7 @@ def make(self, query, pos_kwargs: OptionalKwargs = None, **kwargs) -> X: # noqa return self.lookup(query=query, **kwargs) @classmethod - def from_literal(cls, literal: Literal, **kwargs) -> Self: + def from_literal(cls, literal: Any, **kwargs) -> Self: """ Construct a simple resolver for the given `typing.Literal`. @@ -372,4 +372,5 @@ def from_literal(cls, literal: Literal, **kwargs) -> Self: :return: a simple resolver for the literal values. """ + # todo: how to annotate type annotations? return cls(elements=typing.get_args(literal), **kwargs) From 03e0d7b3ad2ef745435945ef00ceaf5b4e9a1633 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:48:28 +0100 Subject: [PATCH 08/17] add test --- tests/test_api.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 42db94b..370e5c8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,13 +3,15 @@ """Tests for the class resolver.""" import itertools +import typing import unittest from typing import ClassVar, Collection, Optional, Sequence import click +import pytest from click.testing import CliRunner, Result from docdata import parse_docdata -import pytest +from typing_extensions import Literal from class_resolver import ( VERSION, @@ -507,3 +509,12 @@ def test_simple_resolver(): with pytest.raises(ValueError): sr.make(None) assert sr.make(None, default=2) == 2 + + +LiteralType = Literal[0, 1, 2] + + +def test_simple_resolver_for_literal(): + """Test creating a simple resolver for a literal type annotation.""" + sr = SimpleResolver.from_literal(LiteralType) + assert set(map(str, typing.get_args(LiteralType))).issubset(sr.options) From 03ddedca60f82bba328cb15a23a660b99b34d49f Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:54:21 +0100 Subject: [PATCH 09/17] fix type annotation --- src/class_resolver/base.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index 1c6f99a..4087d8e 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -13,13 +13,15 @@ Generic, Iterable, Iterator, + Literal, Mapping, Optional, Set, + Type, + TypeVar, ) from pkg_resources import iter_entry_points -from typing_extensions import Self from .utils import Hint, OptionalKwargs, X, Y, make_callback, normalize_string @@ -317,7 +319,10 @@ def objective(trial: optuna.Trial) -> float: return self.lookup(key) -class SimpleResolver(BaseResolver[X, X]): +L = TypeVar("L", bound=Literal) + + +class SimpleResolver(BaseResolver[X, X], Generic[X]): """ A simple resolver which uses the string representations as key. @@ -360,7 +365,7 @@ def make(self, query, pos_kwargs: OptionalKwargs = None, **kwargs) -> X: # noqa return self.lookup(query=query, **kwargs) @classmethod - def from_literal(cls, literal: Any, **kwargs) -> Self: + def from_literal(cls: Type[BaseResolver[L, L]], literal: L, **kwargs) -> BaseResolver[L, L]: """ Construct a simple resolver for the given `typing.Literal`. @@ -372,5 +377,4 @@ def from_literal(cls, literal: Any, **kwargs) -> Self: :return: a simple resolver for the literal values. """ - # todo: how to annotate type annotations? return cls(elements=typing.get_args(literal), **kwargs) From 9c476c32c3117309dfb3286e4bd98073ef866a4c Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 21:59:19 +0100 Subject: [PATCH 10/17] remove from_literal for this PR typing this seems difficult --- src/class_resolver/base.py | 18 ------------------ tests/test_api.py | 9 --------- 2 files changed, 27 deletions(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index 4087d8e..c09f476 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -319,9 +319,6 @@ def objective(trial: optuna.Trial) -> float: return self.lookup(key) -L = TypeVar("L", bound=Literal) - - class SimpleResolver(BaseResolver[X, X], Generic[X]): """ A simple resolver which uses the string representations as key. @@ -363,18 +360,3 @@ def make(self, query, pos_kwargs: OptionalKwargs = None, **kwargs) -> X: # noqa if pos_kwargs is not None: raise ValueError(f"{self.__class__.__name__} does not support positional arguments.") return self.lookup(query=query, **kwargs) - - @classmethod - def from_literal(cls: Type[BaseResolver[L, L]], literal: L, **kwargs) -> BaseResolver[L, L]: - """ - Construct a simple resolver for the given `typing.Literal`. - - :param literal: - the type annotation for literals. - :param kwargs: - additional keyword-based parameters passed to :meth:`__init__` - - :return: - a simple resolver for the literal values. - """ - return cls(elements=typing.get_args(literal), **kwargs) diff --git a/tests/test_api.py b/tests/test_api.py index 370e5c8..48eae15 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -509,12 +509,3 @@ def test_simple_resolver(): with pytest.raises(ValueError): sr.make(None) assert sr.make(None, default=2) == 2 - - -LiteralType = Literal[0, 1, 2] - - -def test_simple_resolver_for_literal(): - """Test creating a simple resolver for a literal type annotation.""" - sr = SimpleResolver.from_literal(LiteralType) - assert set(map(str, typing.get_args(LiteralType))).issubset(sr.options) From ca4072c5332f3999d53368f2d8a17359d89871a2 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Sun, 5 Mar 2023 22:00:11 +0100 Subject: [PATCH 11/17] cleanup import --- src/class_resolver/base.py | 5 ----- tests/test_api.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index c09f476..039747f 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -3,22 +3,17 @@ """A base resolver.""" import logging -import typing from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, - Any, Collection, Dict, Generic, Iterable, Iterator, - Literal, Mapping, Optional, Set, - Type, - TypeVar, ) from pkg_resources import iter_entry_points diff --git a/tests/test_api.py b/tests/test_api.py index 48eae15..bfadf7e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,7 +3,6 @@ """Tests for the class resolver.""" import itertools -import typing import unittest from typing import ClassVar, Collection, Optional, Sequence @@ -11,7 +10,6 @@ import pytest from click.testing import CliRunner, Result from docdata import parse_docdata -from typing_extensions import Literal from class_resolver import ( VERSION, From 0b28b212276b20a8e47a45ea421c3a00ab9a820d Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Tue, 7 Mar 2023 20:25:21 +0100 Subject: [PATCH 12/17] Update example --- src/class_resolver/base.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index 039747f..42fb112 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -320,15 +320,25 @@ class SimpleResolver(BaseResolver[X, X], Generic[X]): While very minimalistic, it can be quite handy when dealing with simple objects, e.g., - >>> r = SimpleResolver([0, 1, 2, 3], default=0) - >>> r.make(None) - 0 - >>> r.make(3) - 3 - >>> r.make(7) + >>> log_level_resolver = SimpleResolver(["debug", "info", "warning", "error"], default="info") + >>> log_level_resolver.make(None) + "info" + >>> r.make("WARNING") + "warning" + >>> r.make("fatal") Traceback (most recent call last): ... - ValueError: Invalid query=7. Possible queries are {0, 1, 2, 3}. + ValueError: Invalid query=fatal. Possible queries are {"debug", "info", "warning", "error"}. + + We can also benefit from, e.g., creation of command-line options for click + + >>> log_level_option = log_level_resolver.get_option("--log-level") + + Or use the resolver to ensure a type-safe normalization + + >>> import typing + >>> LogLevel = typing.Literal["debug", "info", "warning", "error"] + >>> r: SimpleResolver[LogLevel] = SimpleResolver(["debug", "info", "warning", "error"], default="info") """ # docstr-coverage: inherited From 9ab40c27be8ad96697ad5b0a153a9c9bcbdd31b3 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Thu, 23 Mar 2023 19:55:37 +0100 Subject: [PATCH 13/17] apply black --- src/class_resolver/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/class_resolver/base.py b/src/class_resolver/base.py index 42fb112..491bd25 100644 --- a/src/class_resolver/base.py +++ b/src/class_resolver/base.py @@ -329,13 +329,13 @@ class SimpleResolver(BaseResolver[X, X], Generic[X]): Traceback (most recent call last): ... ValueError: Invalid query=fatal. Possible queries are {"debug", "info", "warning", "error"}. - + We can also benefit from, e.g., creation of command-line options for click - + >>> log_level_option = log_level_resolver.get_option("--log-level") - + Or use the resolver to ensure a type-safe normalization - + >>> import typing >>> LogLevel = typing.Literal["debug", "info", "warning", "error"] >>> r: SimpleResolver[LogLevel] = SimpleResolver(["debug", "info", "warning", "error"], default="info") From b1553cd5f3e140c4727838ed78b0b4920f398347 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Thu, 23 Mar 2023 19:58:13 +0100 Subject: [PATCH 14/17] convert test to unittest.TestCase --- tests/test_api.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index bfadf7e..09155b8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -494,16 +494,28 @@ class AAlt3Base(Alt3Base): self.assertEqual("surprise!", str(e.exception)) -def test_simple_resolver(): - """Test simple resolver.""" - sr = SimpleResolver([0, 1, 2, 3]) - for i in range(4): - assert sr.make(i) == i - assert sr.make(str(i)) == i - with pytest.raises(ValueError): - sr.make(-1) - with pytest.raises(ValueError): - sr.make(4) - with pytest.raises(ValueError): - sr.make(None) - assert sr.make(None, default=2) == 2 +class TestSimpleResolver(unittest.TestCase): + """Tests for the simple resolver.""" + + def setUp(self) -> None: + """Create test instance.""" + self.instance = SimpleResolver([0, 1, 2, 3]) + + def test_make(self): + """Test making valid objects.""" + for i in range(4): + assert self.instance.make(i) == i + assert self.instance.make(str(i)) == i + + def test_make_invalid(self): + """Test making invalid choices.""" + with pytest.raises(ValueError): + self.instance.make(-1) + with pytest.raises(ValueError): + self.instance.make(4) + + def test_default(self): + """Test make's interaction with default.""" + with pytest.raises(ValueError): + self.instance.make(None) + assert self.instance.make(None, default=2) == 2 From 7046dc26d729e0886f6359eccb96828b3a270607 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Thu, 23 Mar 2023 20:16:52 +0100 Subject: [PATCH 15/17] remove use of plain assert --- tests/test_api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 09155b8..72e654c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -248,7 +248,7 @@ def test_variant_generation(self): ), ) for spec in itertools.islice( - tune.suggest.variant_generator.generate_variants(search_space), 2 + tune.suggest.variant_generator.generate_variants(search_space), 2 ): config = {k[0]: v for k, v in spec[0].items()} query = config.pop("query") @@ -504,18 +504,18 @@ def setUp(self) -> None: def test_make(self): """Test making valid objects.""" for i in range(4): - assert self.instance.make(i) == i - assert self.instance.make(str(i)) == i + self.assertEqual(self.instance.make(i), i) + self.assertEqual(self.instance.make(str(i)), i) def test_make_invalid(self): """Test making invalid choices.""" - with pytest.raises(ValueError): + with self.assertRaises(ValueError): self.instance.make(-1) - with pytest.raises(ValueError): + with self.assertRaises(ValueError): self.instance.make(4) def test_default(self): """Test make's interaction with default.""" - with pytest.raises(ValueError): + with self.assertRaises(ValueError): self.instance.make(None) - assert self.instance.make(None, default=2) == 2 + self.assertEqual(self.instance.make(None, default=2), 2) From a056ef32f1ea900d9033e43543dc30e45f52eedc Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Thu, 23 Mar 2023 20:17:44 +0100 Subject: [PATCH 16/17] fix whitespace change --- tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 72e654c..c58f807 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -248,7 +248,7 @@ def test_variant_generation(self): ), ) for spec in itertools.islice( - tune.suggest.variant_generator.generate_variants(search_space), 2 + tune.suggest.variant_generator.generate_variants(search_space), 2 ): config = {k[0]: v for k, v in spec[0].items()} query = config.pop("query") From b6040e4be6366fc3845d9353d7c967806a2f4502 Mon Sep 17 00:00:00 2001 From: Max Berrendorf Date: Thu, 23 Mar 2023 20:21:27 +0100 Subject: [PATCH 17/17] Remove unused import --- tests/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index c58f807..eb89f80 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,7 +7,6 @@ from typing import ClassVar, Collection, Optional, Sequence import click -import pytest from click.testing import CliRunner, Result from docdata import parse_docdata