From 54494c3f6dd3b80021f34ff71806350b2e312f02 Mon Sep 17 00:00:00 2001 From: Marcel Wilson Date: Wed, 25 Oct 2023 08:56:50 -0500 Subject: [PATCH] adding python 3.12 support (#115) * adding 3.12 support #114 --- .github/workflows/lint.yml | 2 +- .github/workflows/poetry.yml | 4 +- .github/workflows/tests.yml | 2 +- .isort.cfg | 1 + Makefile | 31 +++++++ pyproject.toml | 16 +++- screenpy/actions/silently.py | 159 +++++++++-------------------------- tests/test_actions.py | 81 ++++-------------- tests/test_resolutions.py | 5 +- tests/test_settings.py | 15 +--- tests/test_speech_tools.py | 2 +- tox.ini | 7 +- 12 files changed, 122 insertions(+), 203 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 250ea5b3..bb6dac67 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest] steps: diff --git a/.github/workflows/poetry.yml b/.github/workflows/poetry.yml index ae79e474..05bd919d 100644 --- a/.github/workflows/poetry.yml +++ b/.github/workflows/poetry.yml @@ -16,9 +16,9 @@ jobs: max-parallel: 9 fail-fast: false matrix: - python-version: ["3.11"] + python-version: ["3.12"] os: [ubuntu-latest] - poetry-version: ["1.3.2"] + poetry-version: ["1.6.1"] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01337f1c..7e338546 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: max-parallel: 9 fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest] steps: diff --git a/.isort.cfg b/.isort.cfg index f0eb616a..b95063f6 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -4,3 +4,4 @@ multi_line_output = 3 include_trailing_comma = True use_parentheses = True src_paths = screenpy,tests +extend_skip = docs diff --git a/Makefile b/Makefile index 8ece0728..7663417c 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,34 @@ requirements: poetry export --without-hashes --with dev -f requirements.txt > requirements.txt .PHONY: sync update_lock_only update check requirements + +black-check: + black --check . + +black: + black . + +isort-check: + isort . --check + +isort: + isort . + +ruff: + ruff check . + +ruff-fix: + ruff check . --fix --show-fixes + +mypy: + mypy . + +lint: isort-check ruff mypy + +.PHONY: black-check black isort-check isort ruff ruff-fix mypy lint + +pre-check-in: black-check lint + +pre-check-in-fix: black isort ruff-fix mypy + +.PHONY: pre-check-in pre-check-in-fix diff --git a/pyproject.toml b/pyproject.toml index 801c3ec6..d640d84b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.black] -target-version = ['py311'] +target-version = ['py312'] # This pyproject.toml is setup so it can be used with or without poetry and also # supports editable installs (PEP 660) without breaking IDE and linter inspection. @@ -10,6 +10,20 @@ target-version = ['py311'] # PIP: # pip install -e .[dev] +extend-exclude = ''' +# A regex preceded with ^/ will apply only to files and directories +# in the root of the project. +( + \.idea + | \.git + | \.mypy_cache + | \.tox + | \/docs + | ^/setup.py +) + +''' + [tool.poetry] name = "screenpy" version = "4.2.1" diff --git a/screenpy/actions/silently.py b/screenpy/actions/silently.py index 795b17ac..89f2c401 100644 --- a/screenpy/actions/silently.py +++ b/screenpy/actions/silently.py @@ -2,129 +2,20 @@ from __future__ import annotations -from typing import Any, TypeVar, Union, overload +import types +from typing import Any, TypeVar from hamcrest.core.base_matcher import Matcher from screenpy.actor import Actor from screenpy.configuration import settings -from screenpy.exceptions import NotAnswerable, NotPerformable, NotResolvable from screenpy.pacing import the_narrator from screenpy.protocols import Answerable, Performable, Resolvable T = TypeVar("T") -class SilentlyMixin: - """Passthrough to the duck which is being silenced. - - Silently needs to mimic the ducks (i.e. objects) they are wrapping. - All of the attributes from the "duck" should be exposed by the Silently object. - """ - - def __getattr__(self, key: Any) -> Any: - """Passthrough to the silenced duck's getattr.""" - try: - return getattr(self.duck, key) - except AttributeError as exc: - msg = ( - f"{self.__class__.__name__}({self.duck.__class__.__name__}) " - f"has no attribute '{key}'" - ) - raise AttributeError(msg) from exc - - -class SilentlyPerformable(Performable, SilentlyMixin): - """Perform the Performable, but quietly.""" - - def perform_as(self, actor: Actor) -> None: - """Direct the Actor to perform silently.""" - with the_narrator.mic_cable_kinked(): - self.duck.perform_as(actor) - if not settings.UNABRIDGED_NARRATION: - the_narrator.clear_backup() - return - - def __init__(self, duck: Performable): - if not isinstance(duck, Performable): - msg = ( - "SilentlyPerformable only works with Performables." - " Use `Silently` instead." - ) - raise NotPerformable(msg) - self.duck = duck - - -class SilentlyAnswerable(Answerable, SilentlyMixin): - """Answer the Answerable, but quietly.""" - - def answered_by(self, actor: Actor) -> Any: - """Direct the Actor to answer the question silently.""" - with the_narrator.mic_cable_kinked(): - thing = self.duck.answered_by(actor) - if not settings.UNABRIDGED_NARRATION: - the_narrator.clear_backup() - return thing - - def __init__(self, duck: Answerable): - if not isinstance(duck, Answerable): - msg = ( - "SilentlyAnswerable only works with Answerables." - " Use `Silently` instead." - ) - raise NotAnswerable(msg) - self.duck = duck - - -class SilentlyResolvable(Resolvable, SilentlyMixin): - """Resolve the Resolvable, but quietly.""" - - def resolve(self) -> Matcher: - """Produce the Matcher to make the assertion, silently.""" - with the_narrator.mic_cable_kinked(): - res = self.duck.resolve() - if not settings.UNABRIDGED_NARRATION: - the_narrator.clear_backup() - return res - - def __init__(self, duck: Resolvable): - if not isinstance(duck, Resolvable): - msg = ( - "SilentlyResolvable only works with Resolvables." - " Use `Silently` instead." - ) - raise NotResolvable(msg) - self.duck = duck - - -T_duck = Union[ - Answerable, - Performable, - Resolvable, -] -T_silent_duck = Union[ - SilentlyAnswerable, - SilentlyPerformable, - SilentlyResolvable, -] - - -@overload -def Silently(duck: Performable) -> Union[Performable, SilentlyPerformable]: - ... - - -@overload -def Silently(duck: Answerable) -> Union[Answerable, SilentlyAnswerable]: - ... - - -@overload -def Silently(duck: Resolvable) -> Union[Resolvable, SilentlyResolvable]: - ... - - -def Silently(duck: T_duck) -> Union[T_duck, T_silent_duck]: +def Silently(duck: T) -> T: """Silence the duck. Any Performable, Answerable, or Resolvable wrapped in Silently will not be @@ -134,8 +25,7 @@ def Silently(duck: T_duck) -> Union[T_duck, T_silent_duck]: duck: Performable, Answerable, or Resolvable Returns: - SilentlyPerformable, SilentlyAnswerable, or SilentlyResolvable - unless settings.UNABRIDGED_NARRATION is enabled. + Performable, Answerable, or Resolvable Examples:: @@ -157,11 +47,46 @@ def Silently(duck: T_duck) -> Union[T_duck, T_silent_duck]: if settings.UNABRIDGED_NARRATION: return duck + # mypy really doesn't like monkeypatching + # See https://github.com/python/mypy/issues/2427 + if isinstance(duck, Performable): - return SilentlyPerformable(duck) + original_perform_as = duck.perform_as + + def perform_as(self: Performable, actor: Actor) -> None: # noqa: ARG001 + """Direct the Actor to perform silently.""" + with the_narrator.mic_cable_kinked(): + original_perform_as(actor) + if not settings.UNABRIDGED_NARRATION: + the_narrator.clear_backup() + return + + duck.perform_as = types.MethodType(perform_as, duck) # type: ignore[method-assign] + if isinstance(duck, Answerable): - return SilentlyAnswerable(duck) + original_answered_by = duck.answered_by + + def answered_by(self: Answerable, actor: Actor) -> Any: # noqa: ARG001 + """Direct the Actor to answer the question silently.""" + with the_narrator.mic_cable_kinked(): + thing = original_answered_by(actor) + if not settings.UNABRIDGED_NARRATION: + the_narrator.clear_backup() + return thing + + duck.answered_by = types.MethodType(answered_by, duck) # type: ignore[method-assign] + if isinstance(duck, Resolvable): - return SilentlyResolvable(duck) + original_resolve = duck.resolve + + def resolve(self: Resolvable) -> Matcher: # noqa: ARG001 + """Produce the Matcher to make the assertion, silently.""" + with the_narrator.mic_cable_kinked(): + res = original_resolve() + if not settings.UNABRIDGED_NARRATION: + the_narrator.clear_backup() + return res + + duck.resolve = types.MethodType(resolve, duck) # type: ignore[method-assign] return duck diff --git a/tests/test_actions.py b/tests/test_actions.py index b268d188..e487c6c7 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -19,9 +19,6 @@ IsEqualTo, Log, MakeNote, - NotAnswerable, - NotPerformable, - NotResolvable, Pause, Performable, Resolvable, @@ -36,11 +33,6 @@ settings, the_narrator, ) -from screenpy.actions.silently import ( - SilentlyAnswerable, - SilentlyPerformable, - SilentlyResolvable, -) from screenpy.configuration import ScreenPySettings from unittest_protocols import ErrorQuestion from useful_mocks import ( @@ -827,23 +819,14 @@ def test_function_returns_properly(self) -> None: q2 = Silently(FakeAction()) q3 = Silently(FakeResolution()) - assert isinstance(q1, SilentlyAnswerable) - assert isinstance(q2, SilentlyPerformable) - assert isinstance(q3, SilentlyResolvable) - - def test_can_be_instantiated(self) -> None: - q1 = SilentlyAnswerable(FakeQuestion()) - q2 = SilentlyPerformable(FakeAction()) - q3 = SilentlyResolvable(FakeResolution()) - - assert isinstance(q1, SilentlyAnswerable) - assert isinstance(q2, SilentlyPerformable) - assert isinstance(q3, SilentlyResolvable) + assert isinstance(q1, FakeQuestion) + assert isinstance(q2, FakeAction) + assert isinstance(q3, FakeResolution) def test_implements_protocol(self) -> None: - q1 = SilentlyAnswerable(FakeQuestion()) - q2 = SilentlyPerformable(FakeAction()) - q3 = SilentlyResolvable(FakeResolution()) + q1 = Silently(FakeQuestion()) + q2 = Silently(FakeAction()) + q3 = Silently(FakeResolution()) assert isinstance(q1, Answerable) assert isinstance(q2, Performable) @@ -852,31 +835,6 @@ def test_implements_protocol(self) -> None: assert isinstance(q2, Describable) assert isinstance(q3, Describable) - def test_not_performable(self) -> None: - with pytest.raises(NotPerformable) as exc: - SilentlyPerformable(None) # type: ignore - - assert str(exc.value) == ( - "SilentlyPerformable only works with Performables. " - "Use `Silently` instead." - ) - - def test_not_answerable(self) -> None: - with pytest.raises(NotAnswerable) as exc: - SilentlyAnswerable(None) # type: ignore - - assert str(exc.value) == ( - "SilentlyAnswerable only works with Answerables. Use `Silently` instead." - ) - - def test_not_resolvable(self) -> None: - with pytest.raises(NotResolvable) as exc: - SilentlyResolvable(None) # type: ignore - - assert str(exc.value) == ( - "SilentlyResolvable only works with Resolvables. Use `Silently` instead." - ) - def test_passthru_attribute(self) -> None: a = FakeAction() a.describe.return_value = "Happy Thoughts" @@ -886,7 +844,7 @@ def test_passthru_attribute(self) -> None: def test_passthru_attribute_missing(self) -> None: a = FakeAction() silent_a = Silently(a) - msg = "SilentlyPerformable(FakeAction) has no attribute 'definitely_not_real'" + msg = "Mock object has no attribute 'definitely_not_real'" with pytest.raises(AttributeError) as exc: silent_a.definitely_not_real() @@ -895,21 +853,27 @@ def test_passthru_attribute_missing(self) -> None: def test_answerable_answers(self, Tester) -> None: question = FakeQuestion() + original_answered_by = question.answered_by + Silently(question).answered_by(Tester) - question.answered_by.assert_called_once_with(Tester) + original_answered_by.assert_called_once_with(Tester) def test_performable_performs(self, Tester) -> None: action = FakeAction() + original_perform_as = action.perform_as + Silently(action).perform_as(Tester) - action.perform_as.assert_called_once_with(Tester) + original_perform_as.assert_called_once_with(Tester) def test_resolvable_resolves(self) -> None: resolution = FakeResolution() + original_resolve = resolution.resolve + Silently(resolution).resolve() - resolution.resolve.assert_called_once_with() + original_resolve.assert_called_once_with() def test_silently_does_not_log(self, Tester, caplog) -> None: """ @@ -972,19 +936,6 @@ def test_unabridge_from_function( assert mock_clear.call_count == 0 assert mock_flush.call_count == 0 - def test_unabridged_from_class(self, Tester: Actor, mocker: MockerFixture) -> None: - mock_clear = mocker.spy(the_narrator, "clear_backup") - mock_flush = mocker.spy(the_narrator, "flush_backup") - mock_kink = mocker.spy(the_narrator, "mic_cable_kinked") - mock_settings = ScreenPySettings(UNABRIDGED_NARRATION=True) - - with mock.patch(self.settings_path, mock_settings): - Tester.will(SilentlyPerformable(FakeAction())) - - assert mock_kink.call_count == 1 - assert mock_clear.call_count == 1 - assert mock_flush.call_count == 1 - def test_unabridged_set_outside_silently(self, Tester, caplog) -> None: """ Confirm when unabridged flag is set, logging will occur normally. diff --git a/tests/test_resolutions.py b/tests/test_resolutions.py index 9537aefb..57949584 100644 --- a/tests/test_resolutions.py +++ b/tests/test_resolutions.py @@ -240,7 +240,9 @@ def test_description(self) -> None: ctt = ContainsTheText(test_text) - expected_description = "Containing the text 'Wenn ist das Nunstück git und Slotermeyer?'." + expected_description = ( + "Containing the text 'Wenn ist das Nunstück git und Slotermeyer?'." + ) assert ctt.describe() == expected_description @@ -258,7 +260,6 @@ def test_the_test(self) -> None: assert ctv.matches({"key": "value", "play": "Hamlet"}) assert not ctv.matches({"play": "Hamlet"}) - @pytest.mark.parametrize( ("arg", "expected"), ((42, "Containing the value <42>."), ("42", "Containing the value '42'.")), diff --git a/tests/test_settings.py b/tests/test_settings.py index 47a7eb51..dda8863b 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,12 +3,12 @@ from unittest import mock from screenpy import settings as screenpy_settings -from screenpy.narration.stdout_adapter import settings as stdout_adapter_settings from screenpy.configuration import ( + ScreenPySettings, _parse_pyproject_toml, pyproject_settings, - ScreenPySettings, ) +from screenpy.narration.stdout_adapter import settings as stdout_adapter_settings from screenpy.narration.stdout_adapter.configuration import StdOutAdapterSettings @@ -35,21 +35,14 @@ def test__parse_pyproject_toml_file_exists(): with mock.patch("pathlib.Path.open", mock_open): toml_config = _parse_pyproject_toml("screenpy") - assert toml_config == { - "TIMEOUT": 500, - "stdoutadapter": { - "INDENT_SIZE": 500 - } - } + assert toml_config == {"TIMEOUT": 500, "stdoutadapter": {"INDENT_SIZE": 500}} def test_pyproject_settings(): test_config = { "TIMEOUT": 500, "SOMETHING_THAT_DOESNT_EXIST": True, - "stdoutadapter": { - "INDENT_SIZE": 500 - } + "stdoutadapter": {"INDENT_SIZE": 500}, } parse_path = "screenpy.configuration._parse_pyproject_toml" mocked_parse = mock.Mock() diff --git a/tests/test_speech_tools.py b/tests/test_speech_tools.py index 5963aa08..b4b6533f 100644 --- a/tests/test_speech_tools.py +++ b/tests/test_speech_tools.py @@ -57,7 +57,7 @@ def test_indescribable(self) -> None: class TestRepresentProp: def test_str(self): val = "hello\nworld!" - + assert represent_prop(val) == "'hello\\nworld!'" def test_int(self): diff --git a/tox.ini b/tox.ini index e022378d..13d410a0 100644 --- a/tox.ini +++ b/tox.ini @@ -7,16 +7,19 @@ envlist = py38 py39 - python3.10 + py310 py311 + py312 isolated_build = True +skip_missing_interpreters = False [gh-actions] python = 3.8: py38 3.9: py39 - 3.10: python3.10 + 3.10: py310 3.11: py311 + 3.12: py312 [testenv] whitelist_externals =