From 349e9a096c5898ee47c9fafe4392a79f1d8d852f Mon Sep 17 00:00:00 2001 From: Gertjan van Zwieten Date: Mon, 9 Mar 2026 12:17:05 +0100 Subject: [PATCH 1/6] Fix reference to missing error module --- ags/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ags/__init__.py b/ags/__init__.py index fb52c3f..ae641ed 100644 --- a/ags/__init__.py +++ b/ags/__init__.py @@ -7,9 +7,7 @@ def _get_backend_for(path): elif path.endswith(".yml"): from . import yaml as backend else: - from . import error - - raise error.AGSError("unrecognized file format") + raise ValueError(f"unrecognized file format for path {path!r}") return backend From fd7e8762b384eb0fd6cb1b801d0ffb0933d7cc79 Mon Sep 17 00:00:00 2001 From: Gertjan van Zwieten Date: Mon, 9 Mar 2026 12:39:55 +0100 Subject: [PATCH 2/6] Make regex strings raw --- test.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test.py b/test.py index 63d68da..5f0a6e4 100644 --- a/test.py +++ b/test.py @@ -76,8 +76,10 @@ class A: i: int = 10 s: str = 20 - with self.assertRaisesRegex(ValueError, "in .s\(default\): expects str, got int"): - m = _mapping.mapping_for(A) + with self.assertRaisesRegex( + ValueError, r"in .s\(default\): expects str, got int" + ): + _mapping.mapping_for(A) def test_boundargs(self): def f(i: int, s: str): @@ -92,8 +94,10 @@ def f(i: int = 10, s: str = 20): pass sig = signature(f) - with self.assertRaisesRegex(ValueError, "in .s\(default\): expects str, got int"): - m = _mapping.mapping_for(sig) + with self.assertRaisesRegex( + ValueError, r"in .s\(default\): expects str, got int" + ): + _mapping.mapping_for(sig) def test_union(self): for modern in False, True: @@ -145,7 +149,7 @@ def test_exception(self): T = dict[str, list[int]] m = _mapping.mapping_for(T) with self.assertRaisesRegex( - AssertionError, "in \[b\]\[1\]: is not " + AssertionError, r"in \[b\]\[1\]: is not " ): m.unlower({"a": [10, 20], "b": [30, "40", 50]}, self.mysurject) From 9a9b2937eb46e8f9159ead8d371f16186ead94f6 Mon Sep 17 00:00:00 2001 From: Gertjan van Zwieten Date: Mon, 9 Mar 2026 14:20:17 +0100 Subject: [PATCH 3/6] Make unit test insensitive to time zone --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index 5f0a6e4..05d6ab2 100644 --- a/test.py +++ b/test.py @@ -198,7 +198,7 @@ def check_load_dump(self, expect): Demo.B("a", Demo.B.Sub(b"foo", "αβγ")), Demo.B("b", Demo.B.Sub(b"bar", None)), ], - direction=Demo.Right(datetime.fromtimestamp(1753600000)), + direction=Demo.Right(datetime.fromisoformat("2025-07-27T09:06:40")), ) with self.subTest("load"): obj = self.mod.load(StringIO(expect), sig) From 566fcbf2b756347a6b85319f2506dfd35fe4bce0 Mon Sep 17 00:00:00 2001 From: Gertjan van Zwieten Date: Mon, 9 Mar 2026 15:30:18 +0100 Subject: [PATCH 4/6] Support Python 3.10 --- README.md | 2 +- ags/_mapping.py | 3 ++- test.py | 46 +++++++++++++++++++++++++++++++--------------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6a38cdc..503cd82 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ all the backends that follow. This is a convenient way of lowering all arguments of a function to and from a dictionary. -- objects that reduce to a single constructor argument +- objects that reduce to a single constructor argument (Python >= 3.11) Lastly, objects that reduce to their own class and a single argument are identified by that argument. diff --git a/ags/_mapping.py b/ags/_mapping.py index caa4c5a..c411fc0 100644 --- a/ags/_mapping.py +++ b/ags/_mapping.py @@ -4,6 +4,7 @@ import inspect import typing import types +import sys PRIMITIVES = ( @@ -161,7 +162,7 @@ def mapping_for(T) -> Mapping: mappings[param.name] = mapping return Signature(T, mappings) - if hasattr(T, "__reduce__"): + if sys.version_info >= (3, 11) and hasattr(T, "__reduce__"): ret = inspect.signature(T.__reduce__).return_annotation if typing.get_origin(ret) is tuple and len(typing.get_args(ret)) == 2: f, args = typing.get_args(ret) diff --git a/test.py b/test.py index 05d6ab2..c5feb3a 100644 --- a/test.py +++ b/test.py @@ -1,17 +1,19 @@ from dataclasses import dataclass from enum import Enum from inspect import signature -from typing import Union, Literal, Tuple, List, Dict, Optional, Self, Type +import typing from unittest import TestCase from io import StringIO from datetime import date, time, datetime from doctest import DocFileSuite +import sys from ags import _mapping def load_tests(loader, tests, ignore): - tests.addTests(DocFileSuite("README.md")) + if sys.version_info >= (3, 11): + tests.addTests(DocFileSuite("README.md")) return tests @@ -37,7 +39,7 @@ def test_primitive(self): self.assertEqual(self.check(obj, T), obj) def test_literal(self): - T = Literal["abc", 123] + T = typing.Literal["abc", 123] for obj in "abc", 123: self.assertEqual(self.check(obj, T), obj) @@ -49,17 +51,19 @@ def test_bytes(self): self.check(b"abc", bytes) def test_list(self): - self.assertEqual(self.check([1, 2, 3], List[int]), [1, 2, 3]) + self.assertEqual(self.check([1, 2, 3], typing.List[int]), [1, 2, 3]) def test_tuple(self): with self.subTest("uniform"): - self.assertEqual(self.check((1, 2, 3), Tuple[int, ...]), [1, 2, 3]) + self.assertEqual(self.check((1, 2, 3), typing.Tuple[int, ...]), [1, 2, 3]) with self.subTest("pluriform"): - self.assertEqual(self.check((123, "abc"), Tuple[int, str]), [123, "abc"]) + self.assertEqual( + self.check((123, "abc"), typing.Tuple[int, str]), [123, "abc"] + ) def test_dict(self): self.assertEqual( - self.check({"a": 10, "b": 20}, Dict[str, int]), {"a": 10, "b": 20} + self.check({"a": 10, "b": 20}, typing.Dict[str, int]), {"a": 10, "b": 20} ) def test_dataclass(self): @@ -102,17 +106,21 @@ def f(i: int = 10, s: str = 20): def test_union(self): for modern in False, True: with self.subTest("optional", modern=modern): - T = int | None if modern else Optional[int] + T = int | None if modern else typing.Optional[int] self.assertEqual(self.check(123, T), _mapping.OptionalValue(123)) self.assertEqual(self.check(None, T), _mapping.OptionalValue(None)) with self.subTest("union", modern=modern): - T = int | str if modern else Union[int, str] + T = int | str if modern else typing.Union[int, str] self.assertEqual(self.check(123, T), _mapping.UnionValue("int", 123)) self.assertEqual( self.check("abc", T), _mapping.UnionValue("str", "abc") ) with self.subTest("optional-union", modern=modern): - T = int | str | None if modern else Optional[Union[int, str]] + T = ( + int | str | None + if modern + else typing.Optional[typing.Union[int, str]] + ) self.assertEqual( self.check(123, T), _mapping.OptionalValue(_mapping.UnionValue("int", 123)), @@ -132,11 +140,19 @@ class E(Enum): self.assertEqual(self.check(E.b, E), "b") def test_reduce(self): + if sys.version_info < (3, 11): + self.skipTest("reduce is supported as of Python 3.11") + class A: - def __init__(self, x: List[int]): + def __init__(self, x: typing.List[int]): self.x = x - def __reduce__(self) -> Tuple[Type[Self], Tuple[List[int]]]: + def __reduce__( + self, + ) -> typing.Tuple[ + typing.Type[typing.Self], + typing.Tuple[typing.List[int]], + ]: return A, (self.x,) def __eq__(self, other): @@ -165,9 +181,9 @@ class B: @dataclass class Sub: b: bytes - greek: Optional[str] + greek: typing.Optional[str] - abc: Literal["a", "b", "c"] + abc: typing.Literal["a", "b", "c"] sub: Sub @dataclass @@ -178,7 +194,7 @@ class Left: class Right: when: datetime - def func(a: A, b: List[B], direction: Union[Left, Right]): + def func(a: A, b: typing.List[B], direction: typing.Union[Left, Right]): pass From 46381be4823241de55b2f948408fe8f6e9e696b3 Mon Sep 17 00:00:00 2001 From: Gertjan van Zwieten Date: Mon, 9 Mar 2026 16:45:09 +0100 Subject: [PATCH 5/6] Add tests for more modern style annotations --- test.py | 63 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/test.py b/test.py index c5feb3a..f19af40 100644 --- a/test.py +++ b/test.py @@ -51,20 +51,26 @@ def test_bytes(self): self.check(b"abc", bytes) def test_list(self): - self.assertEqual(self.check([1, 2, 3], typing.List[int]), [1, 2, 3]) + for modern in False, True: + with self.subTest(modern=modern): + List = list if modern else typing.List + self.assertEqual(self.check([1, 2, 3], List[int]), [1, 2, 3]) def test_tuple(self): - with self.subTest("uniform"): - self.assertEqual(self.check((1, 2, 3), typing.Tuple[int, ...]), [1, 2, 3]) - with self.subTest("pluriform"): - self.assertEqual( - self.check((123, "abc"), typing.Tuple[int, str]), [123, "abc"] - ) + for modern in False, True: + Tuple = tuple if modern else typing.Tuple + with self.subTest("uniform", modern=modern): + self.assertEqual(self.check((1, 2, 3), Tuple[int, ...]), [1, 2, 3]) + with self.subTest("pluriform", modern=modern): + self.assertEqual( + self.check((123, "abc"), Tuple[int, str]), [123, "abc"] + ) def test_dict(self): - self.assertEqual( - self.check({"a": 10, "b": 20}, typing.Dict[str, int]), {"a": 10, "b": 20} - ) + for modern in False, True: + with self.subTest(modern=modern): + Dict = dict if modern else typing.Dict + self.check({"a": 10, "b": 20}, Dict[str, int]), {"a": 10, "b": 20} def test_dataclass(self): @dataclass @@ -143,23 +149,26 @@ def test_reduce(self): if sys.version_info < (3, 11): self.skipTest("reduce is supported as of Python 3.11") - class A: - def __init__(self, x: typing.List[int]): - self.x = x - - def __reduce__( - self, - ) -> typing.Tuple[ - typing.Type[typing.Self], - typing.Tuple[typing.List[int]], - ]: - return A, (self.x,) - - def __eq__(self, other): - return isinstance(other, A) and other.x == self.x - - a = A([2, 3, 4]) - self.assertEqual(self.check(a, A), [2, 3, 4]) + for modern in False, True: + with self.subTest(modern=modern): + List = list if modern else typing.List + Tuple = tuple if modern else typing.Tuple + Type = type if modern else typing.Type + + class A: + def __init__(self, x: List[int]): + self.x = x + + def __reduce__( + self, + ) -> Tuple[Type[typing.Self], Tuple[List[int]]]: + return A, (self.x,) + + def __eq__(self, other): + return isinstance(other, A) and other.x == self.x + + a = A([2, 3, 4]) + self.assertEqual(self.check(a, A), [2, 3, 4]) def test_exception(self): T = dict[str, list[int]] From 161b478d89b7e38e5629af65441ddb68e9ce4431 Mon Sep 17 00:00:00 2001 From: Gertjan van Zwieten Date: Mon, 9 Mar 2026 12:16:51 +0100 Subject: [PATCH 6/6] Add github testing workflow --- .github/workflows/test.yaml | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..c1c3dff --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,77 @@ +name: test +on: + pull_request: + push: + branches: + - main +defaults: + run: + shell: bash +jobs: + build-python-package: + name: Build Python package + runs-on: ubuntu-latest + outputs: + wheel: ${{ steps.build.outputs.wheel }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install build dependencies + run: python -um pip install flit + - name: Build package + id: build + run: | + # To make the wheels reproducible, set the timestamp of the (files in + # the) generated wheels to the date of the commit. + export SOURCE_DATE_EPOCH=`git show -s --format=%ct` + python -um flit build + echo wheel=`echo dist/*.whl` >> $GITHUB_OUTPUT + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package + path: dist/ + if-no-files-found: error + test: + needs: build-python-package + name: 'Test ${{ matrix.name }}' + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - {name: "baseline", os: ubuntu-latest, python-version: "3.14"} + - {name: "windows", os: windows-latest, python-version: "3.14"} + - {name: "macos", os: macos-latest, python-version: "3.14"} + - {name: "python 3.10", os: ubuntu-latest, python-version: "3.10"} + - {name: "python 3.11", os: ubuntu-latest, python-version: "3.11"} + - {name: "python 3.12", os: ubuntu-latest, python-version: "3.12"} + - {name: "python 3.13", os: ubuntu-latest, python-version: "3.13"} + fail-fast: false + env: + _wheel: ${{ needs.build-python-package.outputs.wheel }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Move source directory + run: mv ags _ags + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Download Python package artifact + uses: actions/download-artifact@v4 + with: + name: python-package + path: dist/ + - name: Install AGS and dependencies + id: install + run: | + python -um pip install --upgrade --upgrade-strategy eager wheel + # Install AGS from `dist` dir created in job `build-python-package`. + python -um pip install --upgrade --upgrade-strategy eager "$_wheel[yaml]" + - name: Test + run: python -m unittest -bv