Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 1 addition & 3 deletions ags/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 2 additions & 1 deletion ags/_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import inspect
import typing
import types
import sys


PRIMITIVES = (
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 61 additions & 32 deletions test.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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)),
Expand All @@ -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\]: <class 'str'> is not <class 'int'>"
AssertionError, r"in \[b\]\[1\]: <class 'str'> is not <class 'int'>"
):
m.unlower({"a": [10, 20], "b": [30, "40", 50]}, self.mysurject)

Expand All @@ -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
Expand All @@ -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


Expand All @@ -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)
Expand Down