From bc199b4ff3f88e225e2fee26c3336a25c3da2724 Mon Sep 17 00:00:00 2001 From: Thomas Calmant Date: Wed, 12 Aug 2026 16:35:22 +0200 Subject: [PATCH 1/2] Fixed the wrong type hints and declared the package as typed The package now ships a py.typed marker, so that type checkers use its hints, and mypy runs in the CI with its configuration in pyproject.toml. Fixing the hints of the class descriptions showed four places using a class description without checking it was there, which crashed on a stream holding a null class description. Contributes to #39. Signed-off-by: Thomas Calmant --- .github/workflows/ci-build.yml | 20 +++++++++++++++++++ javaobj/py.typed | 0 javaobj/utils.py | 11 ++++++++++- javaobj/v1/transformers.py | 3 +++ javaobj/v2/api.py | 7 +++++-- javaobj/v2/beans.py | 4 ++-- javaobj/v2/core.py | 35 +++++++++++++++++++++++++--------- manifest.in | 3 +++ pyproject.toml | 17 +++++++++++++++++ 9 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 javaobj/py.typed diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index fb7b4cf..e592fbe 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -38,6 +38,26 @@ jobs: # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + types: + # Checks the type hints, which are described in comments in v1 and v2 (they + # must stay importable on Python 2.7) and in annotations in v3. The + # configuration is in pyproject.toml. Python 3.12+ is required to parse v3. + timeout-minutes: 10 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install mypy + - name: Check the type hints with mypy + run: mypy + test: needs: lint timeout-minutes: 20 diff --git a/javaobj/py.typed b/javaobj/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/javaobj/utils.py b/javaobj/utils.py index f18f161..c4cd5ee 100644 --- a/javaobj/utils.py +++ b/javaobj/utils.py @@ -35,7 +35,7 @@ import os import struct import sys -from typing import IO, Tuple # noqa: F401 +from typing import IO, Any, Tuple # noqa: F401 # Modified UTF-8 parser from .modifiedutf8 import byte_to_int, decode_modified_utf8 @@ -56,6 +56,7 @@ def log_debug(message, ident=0): + # type: (Any, int) -> None """ Logs a message at debug level @@ -66,6 +67,7 @@ def log_debug(message, ident=0): def log_error(message, ident=0): + # type: (Any, int) -> None """ Logs a message at error level @@ -176,6 +178,7 @@ def hexdump(src, start_offset=0, length=16): unicode_char = chr # pylint:disable=C0103 def bytes_char(c): + # type: (int) -> bytes """ Converts the given character to a bytes string """ @@ -183,6 +186,7 @@ def bytes_char(c): # Python 3 interpreter : bytes & str def to_bytes(data, encoding="UTF-8"): + # type: (Any, str) -> bytes """ Converts the given string to an array of bytes. Returns the first parameter if it is already an array of bytes. @@ -197,6 +201,7 @@ def to_bytes(data, encoding="UTF-8"): return data.encode(encoding) def to_str(data, encoding="UTF-8"): + # type: (Any, str) -> str """ Converts the given parameter to a string. Returns the first parameter if it is already an instance of ``str``. @@ -217,6 +222,7 @@ def to_str(data, encoding="UTF-8"): to_unicode = to_str # pylint:disable=C0103 def read_to_str(data): + # type: (bytes) -> str """ Concats all bytes into a string """ @@ -235,6 +241,7 @@ def read_to_str(data): # Python 2 interpreter : str & unicode def to_str(data, encoding="UTF-8"): + # type: (Any, str) -> str """ Converts the given parameter to a string. Returns the first parameter if it is already an instance of ``str``. @@ -253,6 +260,7 @@ def to_str(data, encoding="UTF-8"): # Python 2 interpreter : str & unicode def to_unicode(data, encoding="UTF-8"): + # type: (Any, str) -> Any """ Converts the given parameter to a string. Returns the first parameter if it is already an instance of ``str``. @@ -270,6 +278,7 @@ def to_unicode(data, encoding="UTF-8"): return decode_modified_utf8(data)[0] def read_to_str(data): + # type: (str) -> str """ Nothing to do in Python 2 """ diff --git a/javaobj/v1/transformers.py b/javaobj/v1/transformers.py index 6f86305..8f14c2c 100644 --- a/javaobj/v1/transformers.py +++ b/javaobj/v1/transformers.py @@ -376,6 +376,9 @@ def create(self, classdesc, unmarshaller): :return: The Python form of the object, or the original JavaObject """ try: + if classdesc.name is None: + raise KeyError(classdesc.name) + mapped_type = self.TYPE_MAPPER[classdesc.name] except KeyError: # Return a JavaObject by default diff --git a/javaobj/v2/api.py b/javaobj/v2/api.py index 102f5fd..fb5e7e1 100644 --- a/javaobj/v2/api.py +++ b/javaobj/v2/api.py @@ -54,7 +54,7 @@ class IJavaStreamParser: """ def run(self): - # type: () -> List[ParsedJavaContent] + # type: () -> List[Optional[ParsedJavaContent]] """ Parses the input stream """ @@ -68,10 +68,13 @@ def dump(self, content): raise NotImplementedError def _read_content(self, type_code, block_data, class_desc=None): - # type: (int, bool, Optional[JavaClassDesc]) -> ParsedJavaContent + # type: (int, bool, Optional[JavaClassDesc]) -> Optional[ParsedJavaContent] """ Parses the next content. Use with care (use only in a transformer) + + :return: The parsed content, None if the stream holds TC_NULL """ + raise NotImplementedError class ObjectTransformer(object): # pylint:disable=R0205 diff --git a/javaobj/v2/beans.py b/javaobj/v2/beans.py index 5406aeb..6b46594 100644 --- a/javaobj/v2/beans.py +++ b/javaobj/v2/beans.py @@ -258,7 +258,7 @@ def __init__(self, class_desc_type): self.inner_classes = [] # type: List[JavaClassDesc] # List of annotations objects - self.annotations = [] # type: List[ParsedJavaContent] + self.annotations = [] # type: List[Optional[ParsedJavaContent]] # The super class of this one, if any self.super_class = None # type: Optional[JavaClassDesc] @@ -409,7 +409,7 @@ def __init__(self): self.field_data = {} # type: Dict[JavaClassDesc, Dict[JavaField, Any]] self.annotations = ( {} - ) # type: Dict[JavaClassDesc, List[ParsedJavaContent]] + ) # type: Dict[JavaClassDesc, List[Optional[ParsedJavaContent]]] self.is_external_instance = False def __str__(self): diff --git a/javaobj/v2/core.py b/javaobj/v2/core.py index 63697df..a038a92 100644 --- a/javaobj/v2/core.py +++ b/javaobj/v2/core.py @@ -123,10 +123,10 @@ def __init__(self, fd, transformers): TerminalCode.TC_EXCEPTION: self._do_exception, TerminalCode.TC_BLOCKDATA: self._do_block_data, TerminalCode.TC_BLOCKDATALONG: self._do_block_data, - } # type: Dict[int, Callable[[int], ParsedJavaContent]] + } # type: Dict[int, Callable[[int], Optional[ParsedJavaContent]]] def run(self): - # type: () -> List[ParsedJavaContent] + # type: () -> List[Optional[ParsedJavaContent]] """ Parses the input stream """ @@ -144,7 +144,7 @@ def run(self): self._reset() # Read content - contents = [] # type: List[ParsedJavaContent] + contents = [] # type: List[Optional[ParsedJavaContent]] while True: self._log.debug("Reading next content") start = self.__fd.tell() @@ -287,9 +287,11 @@ def _do_null(_): return None def _read_content(self, type_code, block_data, class_desc=None): - # type: (int, bool, Optional[JavaClassDesc]) -> ParsedJavaContent + # type: (int, bool, Optional[JavaClassDesc]) -> Optional[ParsedJavaContent] """ Parses the next content + + :return: The parsed content, None if the stream holds TC_NULL """ if not block_data and type_code in ( TerminalCode.TC_BLOCKDATA, @@ -355,17 +357,21 @@ def _read_new_string(self, type_code): return java_str def _read_classdesc(self): - # type: () -> JavaClassDesc + # type: () -> Optional[JavaClassDesc] """ Reads a class description with its type code + + :return: The class description, None if the stream holds TC_NULL """ type_code = self.__reader.read_byte() return self._do_classdesc(type_code) def _do_classdesc(self, type_code): - # type: (int) -> JavaClassDesc + # type: (int) -> Optional[JavaClassDesc] """ Parses a class description + + :return: The class description, None if the stream holds TC_NULL """ if type_code == TerminalCode.TC_CLASSDESC: # Do the real job @@ -467,11 +473,11 @@ def _custom_readObject(self, class_name): raise ValueError("Custom readObject can not be processed") def _read_class_annotations(self, class_desc=None): - # type: (Optional[JavaClassDesc]) -> List[ParsedJavaContent] + # type: (Optional[JavaClassDesc]) -> List[Optional[ParsedJavaContent]] """ Reads the annotations associated to a class """ - contents = [] # type: List[ParsedJavaContent] + contents = [] # type: List[Optional[ParsedJavaContent]] while True: type_code = self.__reader.read_byte() if type_code == TerminalCode.TC_ENDBLOCKDATA: @@ -523,6 +529,9 @@ def _do_object(self, type_code=0): "Reading new object: handle %x, classdesc %s", handle, class_desc ) + if class_desc is None: + raise ValueError("Object without class description") + # Prepare the instance object instance = self._create_instance(class_desc) instance.classdesc = class_desc @@ -561,7 +570,9 @@ def _read_class_data(self, instance): instance.classdesc.get_hierarchy(classes) all_data = {} # type: Dict[JavaClassDesc, Dict[JavaField, Any]] - annotations = {} # type: Dict[JavaClassDesc, List[ParsedJavaContent]] + annotations = ( + {} + ) # type: Dict[JavaClassDesc, List[Optional[ParsedJavaContent]]] for cd in classes: values = {} # type: Dict[JavaField, Any] @@ -680,6 +691,9 @@ def _do_class(self, type_code): Parses a class """ cd = self._read_classdesc() + if cd is None: + raise ValueError("Class without class description") + handle = self._new_handle() class_obj = JavaClass(handle, cd) @@ -693,6 +707,9 @@ def _do_array(self, type_code): Parses an array """ cd = self._read_classdesc() + if cd is None: + raise ValueError("Array without class description") + handle = self._new_handle() if not cd.name or len(cd.name) < 2: raise ValueError("Invalid name in array class description") diff --git a/manifest.in b/manifest.in index cf4e570..d548893 100644 --- a/manifest.in +++ b/manifest.in @@ -6,3 +6,6 @@ include AUTHORS # Include the license file include LICENSE + +# Include the PEP 561 marker, which declares the package as typed +include javaobj/py.typed diff --git a/pyproject.toml b/pyproject.toml index 1955d8c..de96a8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,11 @@ dev = [ [tool.setuptools] packages = ["javaobj", "javaobj.v1", "javaobj.v2", "javaobj.v3"] +# Ship the PEP 561 marker: without it, type checkers ignore the type hints of +# the installed package +[tool.setuptools.package-data] +javaobj = ["py.typed"] + # Produce a universal ``py2.py3-none-any`` wheel: the code base is pure Python # and v1/v2 support both Python 2.7 and 3 (v3 requires 3.12+ but is harmless # dead weight on older interpreters that never import it), so a single wheel @@ -84,3 +89,15 @@ line-length = 110 [tool.ruff.lint] extend-select = ["I"] + +[tool.mypy] +files = ["javaobj"] +# The dependencies declared for old interpreters (enum34, typing) are not +# installed on the version running the check +ignore_missing_imports = true +# v1 and v2 must stay importable on Python 2.7, so they describe their types +# in comments rather than in annotations. Most of their functions are not +# described yet (see issue #39): checking the bodies of those would report +# the types it cannot infer, not actual problems. +check_untyped_defs = false +disallow_untyped_defs = false From bb24722ce5ce8acf41812fbf01f788f47bbebe8b Mon Sep 17 00:00:00 2001 From: Thomas Calmant Date: Wed, 12 Aug 2026 16:42:03 +0200 Subject: [PATCH 2/2] Added tests for the new guards and the parser interface Signed-off-by: Thomas Calmant --- tests/test_v2.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_v2.py b/tests/test_v2.py index e1f4839..fb183a8 100644 --- a/tests/test_v2.py +++ b/tests/test_v2.py @@ -723,6 +723,22 @@ def test_unexpected_blockdata_in_exception(self): with self.assertRaises(ValueError): javaobj.loads(data) + def test_null_class_description(self): + """ + An object, a class and an array all require a class description: + a null one must be reported, and not crash on a missing attribute + """ + for type_code in ( + TerminalCode.TC_OBJECT, + TerminalCode.TC_CLASS, + TerminalCode.TC_ARRAY, + ): + data = STREAM_MAGIC + _tc(type_code) + _tc(TerminalCode.TC_NULL) + with self.assertRaises(ValueError) as context: + javaobj.loads(data) + + self.assertIn("class description", str(context.exception)) + def test_invalid_field_count(self): cd = _classdesc_bytes("Foo", int(ClassDescFlags.SC_SERIALIZABLE), nb_fields=-1) data = STREAM_MAGIC + _tc(TerminalCode.TC_CLASS) + cd @@ -845,6 +861,25 @@ def test_duplicate_handle(self): # ------------------------------------------------------------------------------ +class TestParserInterface(unittest.TestCase): + """ + Tests the IJavaStreamParser interface, which a transformer may be given + """ + + def test_methods_are_abstract(self): + """ + Every method of the interface must be implemented by the parser: + none of them must silently return None + """ + parser = javaobj.api.IJavaStreamParser() + + self.assertRaises(NotImplementedError, parser.run) + self.assertRaises(NotImplementedError, parser.dump, []) + self.assertRaises( + NotImplementedError, parser._read_content, 0, False, None + ) + + class TestBeansValidation(unittest.TestCase): """Direct unit tests for JavaClassDesc.validate() and field access."""