diff --git a/.github/workflows/publish-python-package.yml b/.github/workflows/publish-python-package.yml index f9ee7cd..5af1586 100644 --- a/.github/workflows/publish-python-package.yml +++ b/.github/workflows/publish-python-package.yml @@ -1,49 +1,49 @@ -name: Publish Tag-Versioned Python Package - -on: - push: - branches: - - main - tags: - - 'v*.*.*' - -jobs: - build_and_publish: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - - name: Check for existing dist directory - run: | - if [ -d "dist" ]; then - echo "Error: 'dist' directory already exists." >&2 - exit 1 - fi - - - name: Build distribution - run: | - python setup.py sdist bdist_wheel - - - name: Check distribution - run: | - twine check dist/* - - - name: Upload distribution to PyPI - run: | - twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI }} +name: Publish Tag-Versioned Python Package + +on: + push: + branches: + - main + tags: + - 'v*.*.*' + +jobs: + build_and_publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + + - name: Check for existing dist directory + run: | + if [ -d "dist" ]; then + echo "Error: 'dist' directory already exists." >&2 + exit 1 + fi + + - name: Build distribution + run: | + python setup.py sdist bdist_wheel + + - name: Check distribution + run: | + twine check dist/* + + - name: Upload distribution to PyPI + run: | + twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI }} \ No newline at end of file diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 5c1f9b5..27eae0c 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -1,42 +1,42 @@ -# .github/workflows/python-tests.yml -name: Unit Tests - -on: - push: - branches: - - '*' # Trigger on push to all branches - -jobs: - test: - runs-on: ubuntu-latest # The environment to run the job in - - steps: - - name: Checkout code - uses: actions/checkout@v3 # Checkout the repository code - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' # Specify the Python version to use - - - name: Install dependencies - run: | - if [ -f requirements.txt ]; then - echo "requirements.txt found, installing dependencies..." - python -m pip install --upgrade pip - pip install -r requirements.txt - else - echo "requirements.txt not found, skipping dependency installation." - fi - - - name: Run tests - run: | - mkdir -p test-results # Create directory for test results - python -m unittest discover -s tests -p "*test.py" | tee test-results/test_output.log - # Here, we're redirecting output to a log file in the test-results directory - - - name: Upload test results - uses: actions/upload-artifact@v3 - with: - name: unittest-results - path: test-results/test_output.log +# .github/workflows/python-tests.yml +name: Unit Tests + +on: + push: + branches: + - '*' # Trigger on push to all branches + +jobs: + test: + runs-on: ubuntu-latest # The environment to run the job in + + steps: + - name: Checkout code + uses: actions/checkout@v3 # Checkout the repository code + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' # Specify the Python version to use + + - name: Install dependencies + run: | + if [ -f requirements.txt ]; then + echo "requirements.txt found, installing dependencies..." + python -m pip install --upgrade pip + pip install -r requirements.txt + else + echo "requirements.txt not found, skipping dependency installation." + fi + + - name: Run tests + run: | + mkdir -p test-results # Create directory for test results + python -m unittest discover -s tests -p "*test.py" | tee test-results/test_output.log + # Here, we're redirecting output to a log file in the test-results directory + + - name: Upload test results + uses: actions/upload-artifact@v3 + with: + name: unittest-results + path: test-results/test_output.log diff --git a/.gitignore b/.gitignore index 43e3c46..c3a50c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -.vscode/ -__pycache__/ -build -dist -*.egg-info/ -*.ipynb +.vscode/ +__pycache__/ +build +dist +*.egg-info/ +*.ipynb *temp* \ No newline at end of file diff --git a/README.md b/README.md index e49b115..cf1fbdd 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,107 @@ -# Swizzle Decorator - -The **Swizzle Decorator** for Python enhances attribute lookup methods (`__getattr__` or `__getattribute__`) to facilitate dynamic and flexible retrieval of multiple attributes based on specified arrangements of their names. This concept is reminiscent of swizzling in computer graphics, where it allows efficient access to components of vectors or coordinates in various orders: - -```python -import swizzle - -@swizzle -class Vector: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -print(Vector(1, 2, 3).yzx) # Output: (2, 3, 1) -``` - -## Installation -### From PyPI -```bash -pip install swizzle -``` -### From GitHub -```bash -pip install git+https://github.com/janthmueller/swizzle.git -``` - -## Further Examples - -### Using `swizzle` with `dataclass` - -```python -import swizzle -from dataclasses import dataclass - -@swizzle -@dataclass -class XYZ: - x: int - y: int - z: int - -# Test the swizzle -xyz = XYZ(1, 2, 3) -print(xyz.yzx) # Output: (2, 3, 1) -``` - -### Using `swizzle` with `IntEnum` - -```python -import swizzle -from enum import IntEnum - -@swizzle(meta=True) -class XYZ(IntEnum): - X = 1 - Y = 2 - Z = 3 - -# Test the swizzle -print(XYZ.YXZ) # Output: (, , ) -``` -Setting the `meta` argument to `True` in the swizzle decorator extends the `getattr` behavior of the metaclass, enabling attribute swizzling directly on the class itself. - -### Using `swizzle` with `NamedTuple` - -```python -import swizzle -from typing import NamedTuple - -@swizzle -class XYZ(NamedTuple): - x: int - y: int - z: int - -# Test the swizzle -xyz = XYZ(1, 2, 3) -print(xyz.yzx) # Output: (2, 3, 1) -``` - - -### Sequential matching -Attributes are matched from left to right, starting with the longest substring match. -```python -import swizzle - -@swizzle(meta=True) -class Test: - x = 1 - y = 2 - z = 3 - xy = 4 - yz = 5 - xz = 6 - xyz = 7 - -# Test the swizzle -print(Test.xz) # Output: 6 -print(Test.yz) # Output: 5 -print(Test.xyyz) # Output: (4, 5) -print(Test.xyzx) # Output: (7, 1) -``` - -## To Do -- [ ] Add support for module-level swizzling -- [ ] Swizzle for method args (swizzle+partial) +# Swizzle Decorator + +The **Swizzle Decorator** for Python enhances attribute lookup methods (`__getattr__` or `__getattribute__`) to facilitate dynamic and flexible retrieval of multiple attributes based on specified arrangements of their names. This concept is reminiscent of swizzling in computer graphics, where it allows efficient access to components of vectors or coordinates in various orders: + +```python +import swizzle + +@swizzle +class Vector: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +print(Vector(1, 2, 3).yzx) # Output: (2, 3, 1) +``` + +## Installation +### From PyPI +```bash +pip install swizzle +``` +### From GitHub +```bash +pip install git+https://github.com/janthmueller/swizzle.git +``` + +## Further Examples + +### Using `swizzle` with `dataclass` + +```python +import swizzle +from dataclasses import dataclass + +@swizzle +@dataclass +class XYZ: + x: int + y: int + z: int + +# Test the swizzle +xyz = XYZ(1, 2, 3) +print(xyz.yzx) # Output: (2, 3, 1) +``` + +### Using `swizzle` with `IntEnum` + +```python +import swizzle +from enum import IntEnum + +@swizzle(meta=True) +class XYZ(IntEnum): + X = 1 + Y = 2 + Z = 3 + +# Test the swizzle +print(XYZ.YXZ) # Output: (, , ) +``` +Setting the `meta` argument to `True` in the swizzle decorator extends the `getattr` behavior of the metaclass, enabling attribute swizzling directly on the class itself. + +### Using `swizzle` with `NamedTuple` + +```python +import swizzle +from typing import NamedTuple + +@swizzle +class XYZ(NamedTuple): + x: int + y: int + z: int + +# Test the swizzle +xyz = XYZ(1, 2, 3) +print(xyz.yzx) # Output: (2, 3, 1) +``` + + +### Sequential matching +Attributes are matched from left to right, starting with the longest substring match. +```python +import swizzle + +@swizzle(meta=True) +class Test: + x = 1 + y = 2 + z = 3 + xy = 4 + yz = 5 + xz = 6 + xyz = 7 + +# Test the swizzle +print(Test.xz) # Output: 6 +print(Test.yz) # Output: 5 +print(Test.xyyz) # Output: (4, 5) +print(Test.xyzx) # Output: (7, 1) +``` + +## To Do +- [ ] Add support for module-level swizzling +- [ ] Swizzle for method args (swizzle+partial) diff --git a/setup.py b/setup.py index f0ac177..45d27a9 100644 --- a/setup.py +++ b/setup.py @@ -1,43 +1,43 @@ -# Copyright (c) 2023 Jan T. Müller - -import sys -import os -from setuptools import setup, find_packages -import swizzle - -if sys.version_info < (3, 6): - sys.exit("ERROR: swizzle requires Python 3.6+") - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setup( - name="swizzle", - version=swizzle.__version__, - packages=find_packages(exclude=["tests"]), - author="Jan T. Müller", - author_email="mail@jantmueller.com", - description="The Swizzle Decorator enables the retrieval of multiple attributes, similar to swizzling in computer graphics.", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/janthmueller/swizzle", - project_urls={ - "Documentation": "https://github.com/janthmueller/swizzle/blob/main/README.md", - "Source": "https://github.com/janthmueller/swizzle", - "Tracker": "https://github.com/janthmueller/swizzle/issues", - }, - license="MIT", - classifiers=[ - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3 :: Only", - ], - python_requires=">=3.6", -) - - -# python setup.py sdist bdist_wheel -# twine check dist/* -# twine upload dist/* -> insert token +# Copyright (c) 2023 Jan T. Müller + +import sys +import os +from setuptools import setup, find_packages +import swizzle + +if sys.version_info < (3, 6): + sys.exit("ERROR: swizzle requires Python 3.6+") + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="swizzle", + version=swizzle.__version__, + packages=find_packages(exclude=["tests"]), + author="Jan T. Müller", + author_email="mail@jantmueller.com", + description="The Swizzle Decorator enables the retrieval of multiple attributes, similar to swizzling in computer graphics.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/janthmueller/swizzle", + project_urls={ + "Documentation": "https://github.com/janthmueller/swizzle/blob/main/README.md", + "Source": "https://github.com/janthmueller/swizzle", + "Tracker": "https://github.com/janthmueller/swizzle/issues", + }, + license="MIT", + classifiers=[ + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + ], + python_requires=">=3.6", +) + + +# python setup.py sdist bdist_wheel +# twine check dist/* +# twine upload dist/* -> insert token diff --git a/swizzle/__init__.py b/swizzle/__init__.py index d5d6a7f..932dd16 100755 --- a/swizzle/__init__.py +++ b/swizzle/__init__.py @@ -1,80 +1,80 @@ -from functools import wraps -import sys -import types - -__version__ = "0.1.4" - -def _split(s, sep): - if sep == '': - return list(s) - else: - return s.split(sep) - -def _swizzle(func, sep = None): - def _getattr(obj, name, default=object()): - try: - return func(obj, name) - except AttributeError: - return default - - @wraps(func) - def _swizzle_attributes(obj, name): - """Find attributes of an object that match substrings of a given name.""" - try: - return func(obj, name) - except AttributeError: - pass - if sep is not None: - names = _split(name, sep) - found_attributes = [func(obj, n) for n in names] - else: - found_attributes = [] - sentinel = object() - i = 0 - while i < len(name): - match_found = False - for j in range(len(name), i, -1): - substr = name[i:j] - attr = _getattr(obj, substr, sentinel) - if attr is not sentinel: - found_attributes.append(attr) - i = j # Move index to end of the matched substring - match_found = True - break - if not match_found: - raise AttributeError(f"No matching attribute found for substring: {name[i:]}") - return tuple(found_attributes) - return _swizzle_attributes - -def swizzle(cls=None, meta = False, sep = None): - def decorator(cls): - # Decorate the class's __getattr__ or __getattribute__ - cls_fn = cls.__getattr__ if hasattr(cls, '__getattr__') else cls.__getattribute__ - setattr(cls, cls_fn.__name__, _swizzle(cls_fn, sep)) - - # Handle the meta class - if meta: - meta_cls = type(cls) - if meta_cls == type: - class SwizzleType(meta_cls): pass - meta_cls = SwizzleType - cls = meta_cls(cls.__name__, cls.__bases__, dict(cls.__dict__)) - meta_fn = meta_cls.__getattr__ if hasattr(meta_cls, '__getattr__') else meta_cls.__getattribute__ - setattr(meta_cls, meta_fn.__name__, _swizzle(meta_fn, sep)) - return cls - - if cls is None: - return decorator - else: - return decorator(cls) - -class Swizzle(types.ModuleType): - def __init__(self): - types.ModuleType.__init__(self, __name__) - self.__dict__.update(sys.modules[__name__].__dict__) - - def __call__(self, cls=None, meta=False, sep = None): - return swizzle(cls, meta, sep) - -sys.modules[__name__] = Swizzle() +from functools import wraps +import sys +import types + +__version__ = "0.1.4" + +def _split(s, sep): + if sep == '': + return list(s) + else: + return s.split(sep) + +def _swizzle(func, sep = None): + def _getattr(obj, name, default=object()): + try: + return func(obj, name) + except AttributeError: + return default + + @wraps(func) + def _swizzle_attributes(obj, name): + """Find attributes of an object that match substrings of a given name.""" + try: + return func(obj, name) + except AttributeError: + pass + if sep is not None: + names = _split(name, sep) + found_attributes = [func(obj, n) for n in names] + else: + found_attributes = [] + sentinel = object() + i = 0 + while i < len(name): + match_found = False + for j in range(len(name), i, -1): + substr = name[i:j] + attr = _getattr(obj, substr, sentinel) + if attr is not sentinel: + found_attributes.append(attr) + i = j # Move index to end of the matched substring + match_found = True + break + if not match_found: + raise AttributeError(f"No matching attribute found for substring: {name[i:]}") + return tuple(found_attributes) + return _swizzle_attributes + +def swizzle(cls=None, meta = False, sep = None): + def decorator(cls): + # Decorate the class's __getattr__ or __getattribute__ + cls_fn = cls.__getattr__ if hasattr(cls, '__getattr__') else cls.__getattribute__ + setattr(cls, cls_fn.__name__, _swizzle(cls_fn, sep)) + + # Handle the meta class + if meta: + meta_cls = type(cls) + if meta_cls == type: + class SwizzleType(meta_cls): pass + meta_cls = SwizzleType + cls = meta_cls(cls.__name__, cls.__bases__, dict(cls.__dict__)) + meta_fn = meta_cls.__getattr__ if hasattr(meta_cls, '__getattr__') else meta_cls.__getattribute__ + setattr(meta_cls, meta_fn.__name__, _swizzle(meta_fn, sep)) + return cls + + if cls is None: + return decorator + else: + return decorator(cls) + +class Swizzle(types.ModuleType): + def __init__(self): + types.ModuleType.__init__(self, __name__) + self.__dict__.update(sys.modules[__name__].__dict__) + + def __call__(self, cls=None, meta=False, sep = None): + return swizzle(cls, meta, sep) + +sys.modules[__name__] = Swizzle() \ No newline at end of file diff --git a/tests/attribute_swizzle_test.py b/tests/attribute_swizzle_test.py index d25d3fe..93d1945 100644 --- a/tests/attribute_swizzle_test.py +++ b/tests/attribute_swizzle_test.py @@ -1,128 +1,128 @@ -import unittest -import sys -import os -from dataclasses import dataclass -from enum import IntEnum -from typing import NamedTuple - -# Assuming swizzle is a decorator in your project -sys.path.append(os.path.realpath(os.path.dirname(__file__) + "/..")) -import swizzle - -# Vector class definition -@swizzle -class Vector: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -class TestVectorSwizzling(unittest.TestCase): - - def setUp(self): - self.vector = Vector(1, 2, 3) - - def test_swizzle_yzx(self): - self.assertEqual(self.vector.yzx, (2, 3, 1)) - - def test_invalid_swizzle(self): - with self.assertRaises(AttributeError): - _ = self.vector.nonexistent_attribute - -### 2. **XYZ Dataclass Swizzling Tests** - -@dataclass -@swizzle -class XYZ: - x: int - y: int - z: int - -class TestXYZDataclassSwizzling(unittest.TestCase): - - def setUp(self): - self.xyz = XYZ(1, 2, 3) - - def test_swizzle_yzx(self): - self.assertEqual(self.xyz.yzx, (2, 3, 1)) - - def test_invalid_swizzle(self): - with self.assertRaises(AttributeError): - _ = self.xyz.nonexistent_attribute - -### 3. **XYZ IntEnum Meta Swizzling Tests** - -@swizzle(meta=True) -class XYZEnumMeta(IntEnum): - X = 1 - Y = 2 - Z = 3 - -class TestXYZEnumMetaSwizzling(unittest.TestCase): - - def test_swizzle_meta(self): - self.assertEqual(XYZEnumMeta.YXZ, (XYZEnumMeta.Y, XYZEnumMeta.X, XYZEnumMeta.Z)) - -### 4. **XYZ NamedTuple Swizzling Tests** - -@swizzle -class XYZNamedTuple(NamedTuple): - x: int - y: int - z: int - -class TestXYZNamedTupleSwizzling(unittest.TestCase): - - def setUp(self): - self.xyz = XYZNamedTuple(1, 2, 3) - - def test_swizzle_yzx(self): - self.assertEqual(self.xyz.yzx, (2, 3, 1)) - - def test_invalid_swizzle(self): - with self.assertRaises(AttributeError): - _ = self.xyz.nonexistent_attribute - -### 5. **Sequential Attribute Matching Tests** - -@swizzle(meta=True) -class Test: - x = 1 - y = 2 - z = 3 - xy = 4 - yz = 5 - xz = 6 - xyz = 7 - -class TestSequentialAttributeMatching(unittest.TestCase): - - def test_attribute_values(self): - self.assertEqual(Test.xz, 6) - self.assertEqual(Test.yz, 5) - - def test_composite_swizzle(self): - self.assertEqual(Test.xyyz, (4, 5)) - self.assertEqual(Test.xyzx, (7, 1)) - -### 6. **Invalid Swizzle Requests Tests** - -class TestInvalidSwizzleRequests(unittest.TestCase): - - def test_vector_invalid_swizzle(self): - vector = Vector(1, 2, 3) - with self.assertRaises(AttributeError): - _ = vector.nonexistent_attribute - - def test_xyz_invalid_swizzle(self): - xyz = XYZ(1, 2, 3) - with self.assertRaises(AttributeError): - _ = xyz.nonexistent_attribute - - def test_xyz_namedtuple_invalid_swizzle(self): - xyz = XYZNamedTuple(1, 2, 3) - with self.assertRaises(AttributeError): - _ = xyz.nonexistent_attribute - -if __name__ == "__main__": - unittest.main() +import unittest +import sys +import os +from dataclasses import dataclass +from enum import IntEnum +from typing import NamedTuple + +# Assuming swizzle is a decorator in your project +sys.path.append(os.path.realpath(os.path.dirname(__file__) + "/..")) +import swizzle + +# Vector class definition +@swizzle +class Vector: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class TestVectorSwizzling(unittest.TestCase): + + def setUp(self): + self.vector = Vector(1, 2, 3) + + def test_swizzle_yzx(self): + self.assertEqual(self.vector.yzx, (2, 3, 1)) + + def test_invalid_swizzle(self): + with self.assertRaises(AttributeError): + _ = self.vector.nonexistent_attribute + +### 2. **XYZ Dataclass Swizzling Tests** + +@dataclass +@swizzle +class XYZ: + x: int + y: int + z: int + +class TestXYZDataclassSwizzling(unittest.TestCase): + + def setUp(self): + self.xyz = XYZ(1, 2, 3) + + def test_swizzle_yzx(self): + self.assertEqual(self.xyz.yzx, (2, 3, 1)) + + def test_invalid_swizzle(self): + with self.assertRaises(AttributeError): + _ = self.xyz.nonexistent_attribute + +### 3. **XYZ IntEnum Meta Swizzling Tests** + +@swizzle(meta=True) +class XYZEnumMeta(IntEnum): + X = 1 + Y = 2 + Z = 3 + +class TestXYZEnumMetaSwizzling(unittest.TestCase): + + def test_swizzle_meta(self): + self.assertEqual(XYZEnumMeta.YXZ, (XYZEnumMeta.Y, XYZEnumMeta.X, XYZEnumMeta.Z)) + +### 4. **XYZ NamedTuple Swizzling Tests** + +@swizzle +class XYZNamedTuple(NamedTuple): + x: int + y: int + z: int + +class TestXYZNamedTupleSwizzling(unittest.TestCase): + + def setUp(self): + self.xyz = XYZNamedTuple(1, 2, 3) + + def test_swizzle_yzx(self): + self.assertEqual(self.xyz.yzx, (2, 3, 1)) + + def test_invalid_swizzle(self): + with self.assertRaises(AttributeError): + _ = self.xyz.nonexistent_attribute + +### 5. **Sequential Attribute Matching Tests** + +@swizzle(meta=True) +class Test: + x = 1 + y = 2 + z = 3 + xy = 4 + yz = 5 + xz = 6 + xyz = 7 + +class TestSequentialAttributeMatching(unittest.TestCase): + + def test_attribute_values(self): + self.assertEqual(Test.xz, 6) + self.assertEqual(Test.yz, 5) + + def test_composite_swizzle(self): + self.assertEqual(Test.xyyz, (4, 5)) + self.assertEqual(Test.xyzx, (7, 1)) + +### 6. **Invalid Swizzle Requests Tests** + +class TestInvalidSwizzleRequests(unittest.TestCase): + + def test_vector_invalid_swizzle(self): + vector = Vector(1, 2, 3) + with self.assertRaises(AttributeError): + _ = vector.nonexistent_attribute + + def test_xyz_invalid_swizzle(self): + xyz = XYZ(1, 2, 3) + with self.assertRaises(AttributeError): + _ = xyz.nonexistent_attribute + + def test_xyz_namedtuple_invalid_swizzle(self): + xyz = XYZNamedTuple(1, 2, 3) + with self.assertRaises(AttributeError): + _ = xyz.nonexistent_attribute + +if __name__ == "__main__": + unittest.main()