Repository: lucsorel/py2puml

Issue:
Arbitrary-length homogeneous tuple type unsupported
For example, `tuple[int, ...]`.
This raises an exception along the lines of:

```
ValueError: Could not resolve type ... in module <module 'some_module' from 'some_module.py'>: it needs to be imported explicitly.
```

Not sure how to best go about working around this as I don't think there's any real alternative to that type.

Discussion:
@lucsorel: thank you @jtc42 for reporting this issue. I did not expect to handle an ellipsis (the `...`) in a type annotation, which just should be ignored in this case. It should be rather straightforward to fix, my problem is just the lack of time for now.@jtc42: No problem, I'm happy to take a look and submit a PR. New to this codebase though so if you have an idea where the exclusion should be set up I can have a play around, see if I get anywhere.@0xmzk: This was an issue for me when using numpy's NDArray type, so I've put together a fix in the linked PR. Instead of ignoring the ellipsis completely it just forwards it to `compound_short_type_parts` which are used to generate the puml. If we ignored it then `Tuple[int, ...]` would result in `Tuple[int , ]` in the puml if I'm not mistaken.@lucsorel: 🙏 thank you very much @0xmzk for being pro-active and submitting a PR. Before merging it, I would just add the Tuple[int, ...] in an integration test if it is ok for you?@0xmzk: > 🙏 thank you very much [@0xmzk](https://github.com/0xmzk) for being pro-active and submitting a PR. Before merging it, I would just add the Tuple[int, ...] in an integration test if it is ok for you?

Yeah no problems. Will try to check it out this day.@0xmzk: > 🙏 thank you very much [@0xmzk](https://github.com/0xmzk) for being pro-active and submitting a PR. Before merging it, I would just add the Tuple[int, ...] in an integration test if it is ok for you?

See updated PR (https://github.com/lucsorel/py2puml/pull/141)@lucsorel: hi @0xmzk 
I added you to the list of contributors and bumped the project's version. Is that ok for you?

Then I will merge the PR and publish the new version on PyPI 🙂 @0xmzk: Sounds good. Thanks!@lucsorel: fixed by https://github.com/lucsorel/py2puml/pull/141, thanks to @0xmzk contribution 🙏 

Install version 0.10.0+ to support ellipses in type annotations.

Solution patch:
diff --git a/README.md b/README.md
index 48966b3..9a051f1 100644
--- a/README.md
+++ b/README.md
@@ -202,6 +202,7 @@ poetry run pytest -v --cov=py2puml --cov-branch --cov-report term-missing --cov-
 
 # Changelog
 
+* `0.10.0`: support ellipsis in type annotations (tuple with arbitrary length)
 * `0.9.1`: improved 0.7.2 by adding the current working directory at the beginning of the sys.path to resolve the module paths of the project being inspected.
 Fix url to PlantUML logo on the README.md page
 * `0.9.0`: add classes defined in `__init__.py` files to plantuml output; replaced yapf by the ruff formatter
@@ -234,6 +235,7 @@ Unless stated otherwise all works are licensed under the [MIT license](http://sp
 * [Julien Jerphanion](https://github.com/jjerphan)
 * [Luis Fernando Villanueva Pérez](https://github.com/jonykalavera)
 * [Konstantin Zangerle](https://github.com/justkiddingcode)
+* [Mieszko](https://github.com/0xmzk)
 
 ## Pull requests
 
diff --git a/py2puml/cli.py b/py2puml/cli.py
index 694eca5..fcea7f4 100644
--- a/py2puml/cli.py
+++ b/py2puml/cli.py
@@ -16,7 +16,7 @@ def run():
 
     argparser = ArgumentParser(description='Generate PlantUML class diagrams to document your Python application.')
 
-    argparser.add_argument('-v', '--version', action='version', version='py2puml 0.9.1')
+    argparser.add_argument('-v', '--version', action='version', version='py2puml 0.10.0')
     argparser.add_argument('path', metavar='path', type=str, help='the filepath to the domain')
     argparser.add_argument('module', metavar='module', type=str, help='the module name of the domain', default=None)
 
diff --git a/py2puml/parsing/astvisitors.py b/py2puml/parsing/astvisitors.py
index cb53ee9..911d721 100644
--- a/py2puml/parsing/astvisitors.py
+++ b/py2puml/parsing/astvisitors.py
@@ -206,7 +206,6 @@ def shorten_compound_type_annotation(type_annotation: str, module_resolver: Modu
     compound_short_type_parts: List[str] = []
     associated_types: List[str] = []
     for compound_type_part in compound_type_parts:
-        # characters like '[', ']', ',', '|'
         if compound_type_part in SPLITTING_CHARACTERS:
             if compound_type_part == ',':
                 compound_short_type_parts.append(', ')
diff --git a/py2puml/parsing/compoundtypesplitter.py b/py2puml/parsing/compoundtypesplitter.py
index 71d4629..7dbb417 100644
--- a/py2puml/parsing/compoundtypesplitter.py
+++ b/py2puml/parsing/compoundtypesplitter.py
@@ -9,7 +9,7 @@ FORWARD_REFERENCES: Pattern = re_compile(r"ForwardRef\('([^']+)'\)")
 IS_COMPOUND_TYPE: Pattern = re_compile(r'^[a-z|A-Z|0-9|\[|\]|\.|,|\s|_|\|]+$')
 
 # characters involved in the build-up of compound types
-SPLITTING_CHARACTERS = '[', ']', ',', '|'
+SPLITTING_CHARACTERS = '[', ']', ',', '|', '...'
 
 # 'None' in 'Union[str, None]' type signature is changed into 'NoneType' when inspecting a module
 LAST_NONETYPE_IN_UNION: Pattern = re_compile(r'Union\[(?:(?:[^\[\]])*NoneType)')
diff --git a/pyproject.toml b/pyproject.toml
index a4d2539..d4a27ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "py2puml"
-version = "0.9.1"
+version = "0.10.0"
 description = "Generate PlantUML class diagrams to document your Python application."
 keywords = ["class diagram", "PlantUML", "documentation", "inspection", "AST"]
 readme = "README.md"


Test patch:
diff --git a/tests/asserts/attribute.py b/tests/asserts/attribute.py
index af4f340..2b2d08d 100644
--- a/tests/asserts/attribute.py
+++ b/tests/asserts/attribute.py
@@ -2,6 +2,6 @@ from py2puml.domain.umlclass import UmlAttribute
 
 
 def assert_attribute(attribute: UmlAttribute, expected_name: str, expected_type: str, expected_staticity: bool):
-    assert attribute.name == expected_name
-    assert attribute.type == expected_type
-    assert attribute.static == expected_staticity
+    assert attribute.name == expected_name, f"Got '{attribute.name}' but expected '{expected_name}'"
+    assert attribute.type == expected_type, f"Got '{attribute.type}' but expected '{expected_type}'"
+    assert attribute.static == expected_staticity, f"Got '{attribute.static}' but expected '{expected_staticity}'"
diff --git a/tests/modules/withcompoundtypewithdigits.py b/tests/modules/withcompoundtypewithdigits.py
index bb856a7..63bf5b5 100644
--- a/tests/modules/withcompoundtypewithdigits.py
+++ b/tests/modules/withcompoundtypewithdigits.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Tuple
 
 
 class IPv6:
@@ -12,3 +12,8 @@ class Multicast:
     def __init__(self, address: IPv6, repetition: int):
         # List[IPv6] must be parsed
         self.addresses: List[IPv6] = [address] * repetition
+
+
+class Network:
+    def __init__(self, network_devices: Tuple[IPv6, ...]):
+        self.devices = network_devices
diff --git a/tests/py2puml/inspection/test_inspectclass.py b/tests/py2puml/inspection/test_inspectclass.py
index 8485fcd..7ec851d 100644
--- a/tests/py2puml/inspection/test_inspectclass.py
+++ b/tests/py2puml/inspection/test_inspectclass.py
@@ -161,7 +161,7 @@ def test_inspect_module_should_handle_compound_types_with_numbers_in_their_name(
     fqdn = 'tests.modules.withcompoundtypewithdigits'
     inspect_module(import_module(fqdn), fqdn, domain_items_by_fqn, domain_relations)
 
-    assert len(domain_items_by_fqn) == 2, 'two classes must be inspected'
+    assert len(domain_items_by_fqn) == 3, 'three classes must be inspected'
 
     # IPv6 UmlClass
     ipv6_umlitem: UmlClass = domain_items_by_fqn[f'{fqdn}.IPv6']
@@ -174,3 +174,9 @@ def test_inspect_module_should_handle_compound_types_with_numbers_in_their_name(
     assert len(multicast_umlitem.attributes) == 1, '1 attributes of Multicast must be inspected'
     address_attribute = multicast_umlitem.attributes[0]
     assert_attribute(address_attribute, 'addresses', 'List[IPv6]', expected_staticity=False)
+
+    # Network UmlClass
+    network_umlitem: UmlClass = domain_items_by_fqn[f'{fqdn}.Network']
+    assert len(network_umlitem.attributes) == 1, '1 attributes of Network must be inspected'
+    devices_attribute = network_umlitem.attributes[0]
+    assert_attribute(devices_attribute, 'devices', 'Tuple[IPv6, ...]', expected_staticity=False)
diff --git a/tests/py2puml/parsing/test_astvisitors.py b/tests/py2puml/parsing/test_astvisitors.py
index d1f88f5..44d837d 100644
--- a/tests/py2puml/parsing/test_astvisitors.py
+++ b/tests/py2puml/parsing/test_astvisitors.py
@@ -29,6 +29,8 @@ class ParseMyConstructorArguments:
         y_a: Union[int, float, None],
         x_b: int | float,
         y_b: int | float | None,
+        # arbitrary-length homogenous tuple type
+        bb: Tuple[int, ...],
         # an argument with a default value
         a_default_string: str = 'text',
         # positional and keyword wildcard arguments
@@ -46,7 +48,7 @@ def test_SignatureVariablesCollector_collect_arguments():
     collector.visit(constructor_ast)
 
     assert collector.class_self_id == 'me'
-    assert len(collector.variables) == 10, 'all the arguments must be detected'
+    assert len(collector.variables) == 11, 'all the arguments must be detected'
     assert_Variable(collector.variables[0], 'an_int', 'int', constructor_source)
     assert_Variable(collector.variables[1], 'an_untyped', None, constructor_source)
     assert_Variable(
@@ -58,9 +60,10 @@ def test_SignatureVariablesCollector_collect_arguments():
     assert_Variable(collector.variables[5], 'x_b', 'int | float', constructor_source)
     assert_Variable(collector.variables[6], 'y_b', 'int | float | None', constructor_source)
 
-    assert_Variable(collector.variables[7], 'a_default_string', 'str', constructor_source)
-    assert_Variable(collector.variables[8], 'args', None, constructor_source)
-    assert_Variable(collector.variables[9], 'kwargs', None, constructor_source)
+    assert_Variable(collector.variables[7], 'bb', 'Tuple[int, ...]', constructor_source)
+    assert_Variable(collector.variables[8], 'a_default_string', 'str', constructor_source)
+    assert_Variable(collector.variables[9], 'args', None, constructor_source)
+    assert_Variable(collector.variables[10], 'kwargs', None, constructor_source)
 
 
 @mark.parametrize(
@@ -246,6 +249,22 @@ def test_AssignedVariablesCollector_multiple_assignments_separate_variable_from_
                 },
             },
         ),
+        (
+            # new test case for Tuple[int, ...]
+            'typing.Tuple[int, ...]',
+            'Tuple[int, ...]',
+            ['typing.Tuple', 'builtins.int'],
+            {
+                '__name__': 'testmodule',
+                '__builtins__': {
+                    'int': {
+                        '__module__': 'builtins',
+                        '__name__': 'int',
+                    },
+                },
+                'Tuple': Tuple,
+            },
+        ),
     ],
 )
 def test_shorten_compound_type_annotation(
diff --git a/tests/py2puml/test__init__.py b/tests/py2puml/test__init__.py
index 27cb6e1..ec192b8 100644
--- a/tests/py2puml/test__init__.py
+++ b/tests/py2puml/test__init__.py
@@ -3,7 +3,7 @@ from tests import __description__, __version__
 
 # Ensures the library version is modified in the pyproject.toml file when upgrading it (pull request)
 def test_version():
-    assert __version__ == '0.9.1'
+    assert __version__ == '0.10.0'
 
 
 # Description also output in the CLI