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 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/__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 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 63d68da..f19af40 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,18 +51,26 @@ 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]) + 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), Tuple[int, ...]), [1, 2, 3]) - with self.subTest("pluriform"): - self.assertEqual(self.check((123, "abc"), 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}, 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 @@ -76,8 +86,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,23 +104,29 @@ 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: 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)), @@ -128,24 +146,35 @@ class E(Enum): self.assertEqual(self.check(E.b, E), "b") def test_reduce(self): - class A: - def __init__(self, x: List[int]): - self.x = x + if sys.version_info < (3, 11): + self.skipTest("reduce is supported as of Python 3.11") + + 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[Self], Tuple[List[int]]]: - return A, (self.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 + 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]) + a = A([2, 3, 4]) + self.assertEqual(self.check(a, A), [2, 3, 4]) 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) @@ -161,9 +190,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 @@ -174,7 +203,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 @@ -194,7 +223,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)