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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ hs_err_pid* # virtual machine crash logs, see http://www.java.com/en/download/he
**/__pycache__
**/dist
**/*.egg-info
**/.pytype
**/.mypy_cache
build/**
.coverage
coverage.xml
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ This means adding a properly structured module within the CLI package is all tha
pip install -e ".[dev]"
```

An editable install (`pip install -e .`) is required before running tests or building docs. The package uses `importlib.metadata` for version resolution at runtime, which only works when the package is registered in the environment. You can no longer run `pytest` or `pytype` directly against the source tree without installing first. If you want to avoid pulling in all dependencies, `pip install -e . --no-deps` is sufficient to register the package metadata.
An editable install (`pip install -e .`) is required before running tests or building docs. The package uses `importlib.metadata` for version resolution at runtime, which only works when the package is registered in the environment. You can no longer run `pytest` or `mypy` directly against the source tree without installing first. If you want to avoid pulling in all dependencies, `pip install -e . --no-deps` is sufficient to register the package metadata.

## Local Development

Expand Down
2 changes: 1 addition & 1 deletion build-tools/clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Directories to remove
CLEAN_DIRS = [
"build", "dist", "*.egg-info", "mmif_python-*",
".pytest_cache", ".pytype", ".hypothesis",
".pytest_cache", ".mypy_cache", ".hypothesis",
"tests/.hypothesis", "htmlcov",
"docs-test",
]
Expand Down
8 changes: 4 additions & 4 deletions build-tools/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
This script is equivalent to::

pip install -e ".[test]"
pytype mmif
python -m mypy
python -m pytest --cov=mmif --cov-report=xml -m "not slow"
"""
import argparse
Expand Down Expand Up @@ -51,10 +51,10 @@ def main():
cwd=project_root,
)

# Run pytype static analysis
print("\n--- Running pytype ---")
# Run mypy static analysis (configured in pyproject.toml)
print("\n--- Running mypy ---")
run_command(
["pytype", "mmif"],
[sys.executable, "-m", "mypy"],
cwd=project_root,
)

Expand Down
11 changes: 6 additions & 5 deletions mmif/serialize/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"""

from .annotation import *
from .annotation import __all__ as anno_all
from .mmif import *
from .mmif import __all__ as mmif_all
from .producer import *
from .producer import __all__ as producer_all
from .view import *
from .view import __all__ as view_all

__all__ = anno_all + mmif_all + producer_all + view_all
# a literal, so that type checkers can follow the star import of this package
__all__ = [
'Annotation', 'AnnotationProperties', 'Contain', 'Document',
'DocumentProperties', 'EmptyOutputWarning', 'Mmif', 'ProducerContext',
'Text', 'View', 'ViewMetadata', 'canonicalize_prop_name',
]
76 changes: 41 additions & 35 deletions mmif/serialize/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import typing
import warnings
from collections.abc import Iterator, MutableMapping
from typing import Optional, TypeVar, Union
from typing import TYPE_CHECKING, Any, TypeVar, cast
from urllib.parse import urlparse

import mmif_docloc_http
Expand All @@ -26,6 +26,9 @@
from .. import AnnotationTypes, DocumentTypes
from .model import PRMTV_TYPES, MmifObject

if TYPE_CHECKING:
from .view import View

__all__ = ['Annotation', 'AnnotationProperties', 'Document', 'DocumentProperties', 'Text', 'canonicalize_prop_name']

T = TypeVar('T')
Expand Down Expand Up @@ -84,16 +87,16 @@ class Annotation(MmifObject):
MmifObject that represents an annotation in a MMIF view.
"""

def __init__(self, anno_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
def __init__(self, anno_obj: bytes | str | dict | None = None, *_) -> None:
self._type: ThingTypesBase = ThingTypesBase('')
# values that are not stored on the annotation itself but cached at
# load time: properties recorded by capital `Annotation`s (on
# documents) and computed `start`/`end` (on `targets` annotations)
self._props_ephemeral: AnnotationProperties = AnnotationProperties(None, self)
# the view holding this annotation; backs `parent` and the lookup of
# view-level `contains` defaults
self._parent_view = None
self._alignments = {} # to hold alignment information (Alignment anno long_id -> aligned anno long_id)
self._parent_view: 'View | None' = None
self._alignments: dict = {} # to hold alignment information (Alignment anno long_id -> aligned anno long_id)
self.reserved_names.update(('_props_ephemeral', '_parent_view', '_alignments'))
if not hasattr(self, 'properties'): # don't overwrite DocumentProperties on super() call
self.properties: AnnotationProperties = AnnotationProperties(None, self)
Expand Down Expand Up @@ -123,7 +126,7 @@ def _cache_alignment(self, alignment_ann: 'Annotation', alignedto_ann: 'Annotati
"""
self._alignments[alignment_ann] = alignedto_ann

def aligned_to_by(self, alignment: 'Annotation') -> 'Optional[Annotation]':
def aligned_to_by(self, alignment: 'Annotation') -> 'Annotation | None':
"""
Retrieves the other side of ``Alignment`` annotation that has this annotation on one side.

Expand Down Expand Up @@ -175,7 +178,7 @@ def _resolved_properties(self) -> dict[str, typing.Any]:

:return: a new dict of canonical property names to values
"""
resolved = {}
resolved: dict = {}
if self._parent_view is not None:
defaults = self._parent_view.metadata.contains.get(self._type)
if defaults:
Expand All @@ -184,7 +187,7 @@ def _resolved_properties(self) -> dict[str, typing.Any]:
resolved.update(self.properties.items())
return resolved

def is_type(self, at_type: Union[str, ThingTypesBase]) -> bool:
def is_type(self, at_type: str | ThingTypesBase) -> bool:
"""
Check if the @type of this object matches.
"""
Expand All @@ -197,7 +200,7 @@ def at_type(self) -> ThingTypesBase:
return self._type

@at_type.setter
def at_type(self, at_type: Union[str, ThingTypesBase]) -> None:
def at_type(self, at_type: str | ThingTypesBase) -> None:
if isinstance(at_type, str):
self._type = ThingTypesBase.from_str(at_type)
elif isinstance(at_type, type) and issubclass(at_type, ThingTypesBase):
Expand All @@ -208,8 +211,9 @@ def at_type(self, at_type: Union[str, ThingTypesBase]) -> None:
# Use ``model_construct()`` to skip Pydantic field validation —
# direct ``__init__`` rejects types with required fields
# (e.g., ``Alignment`` requires ``source``/``target``).
instance = at_type.model_construct()
instance.initialized_from = at_type.uri
vocab_type: Any = at_type
instance = vocab_type.model_construct()
instance.initialized_from = vocab_type.uri
self._type = instance
else:
self._type = at_type
Expand Down Expand Up @@ -282,7 +286,7 @@ def _short_id(self) -> str:

@staticmethod
def check_prop_value_is_simple_enough(
value: Union[PRMTV_TYPES, LIST_PRMTV, LIST_LIST_PRMTV, DICT_PRMTV, DICT_LIST_PRMTV]) -> bool:
value: PRMTV_TYPES | LIST_PRMTV | LIST_LIST_PRMTV | DICT_PRMTV | DICT_LIST_PRMTV) -> bool:

def json_primitives(x):
return isinstance(x, typing.get_args(PRMTV_TYPES))
Expand All @@ -299,7 +303,7 @@ def json_primitives_list_of_list(x):
or (isinstance(value, dict) and all(map(lambda x: isinstance(x[0], str) and (json_primitives(x[1]) or json_primitives_list(x[1])), value.items())))

def add_property(self, name: str,
value: Union[PRMTV_TYPES, LIST_PRMTV, LIST_LIST_PRMTV, DICT_PRMTV, DICT_LIST_PRMTV]) -> None:
value: PRMTV_TYPES | LIST_PRMTV | LIST_LIST_PRMTV | DICT_PRMTV | DICT_LIST_PRMTV) -> None:
"""
Adds a property to the annotation's properties. A deprecated spelling
of an aliased property (e.g. ``frameType``) is stored under its
Expand Down Expand Up @@ -410,7 +414,7 @@ class Document(Annotation):
#: the spellings of the ``text`` property in :meth:`add_property`
_TEXT_PROP_NAMES = frozenset({'text', '@text', '_text'})

def __init__(self, doc_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
def __init__(self, doc_obj: bytes | str | dict | None = None, *_) -> None:
# see https://github.com/clamsproject/mmif-python/issues/226 for discussion
# around the property stores of a document
# (names changed since, `existing` >> `ephemeral` and `temporary` >> `pending`)
Expand All @@ -424,14 +428,14 @@ def __init__(self, doc_obj: Optional[Union[bytes, str, dict]] = None, *_) -> Non
self._props_pending: AnnotationProperties = AnnotationProperties(None, self)
self.reserved_names.add('_props_pending')

self._type: Union[ThingTypesBase, DocumentTypesBase] = ThingTypesBase('')
self._type: ThingTypesBase | DocumentTypesBase = ThingTypesBase('')
self.properties: DocumentProperties = DocumentProperties(None, self)
self.disallow_additional_properties()
self._attribute_classes = {'properties': DocumentProperties}
super().__init__(doc_obj)

def add_property(self, name: str,
value: Union[PRMTV_TYPES, LIST_PRMTV]
value: PRMTV_TYPES | LIST_PRMTV | LIST_LIST_PRMTV | DICT_PRMTV | DICT_LIST_PRMTV
) -> None:
"""
Adds a property to the document's properties.
Expand Down Expand Up @@ -470,7 +474,7 @@ def add_property(self, name: str,
# that already existed refuse `add_property`; "already existed" is
# defined by the producer context
if name in self._TEXT_PROP_NAMES:
text = Text(value)
text = Text(cast(str | dict, value))
current = self.properties.text
if (self.location is not None or current.value) and text != current:
raise self._read_only_error('text')
Expand All @@ -481,11 +485,12 @@ def add_property(self, name: str,
raise self._read_only_error(name)
self.properties.mime = mime
elif name == "location":
location = cast(str, value)
requested = DocumentProperties()
requested.location = value
requested.location = location
if self.location is not None and self.location != requested.location:
raise self._read_only_error(name)
self.location = value
self.location = location
elif self.check_prop_value_is_simple_enough(value):
self._props_pending[name] = value
else:
Expand Down Expand Up @@ -603,7 +608,7 @@ def text_language(self, lang_code: str) -> None:
def text_value(self) -> str:
if self._type == DocumentTypes.TextDocument:
if self.location:
f = open(self.location_path(nonexist_ok=False), 'r', encoding='utf8')
f = open(cast(str, self.location_path(nonexist_ok=False)), 'r', encoding='utf8')
textvalue = f.read()
f.close()
return textvalue
Expand All @@ -620,7 +625,7 @@ def text_value(self, text_value: str) -> None:
raise ValueError("Only TextDocument can have `text` field.")

@property
def location(self) -> Optional[str]:
def location(self) -> str | None:
"""
``location`` property must be a legitimate URI. That is, should the document be a local file
then the file:// scheme must be used.
Expand All @@ -632,21 +637,21 @@ def location(self) -> Optional[str]:
def location(self, location: str) -> None:
self.properties.location = location

def location_scheme(self) -> Optional[str]:
def location_scheme(self) -> str | None:
"""
Retrieves URI scheme of the document location.
Returns None when no location is set.
"""
return self.properties.location_scheme()

def location_address(self) -> Optional[str]:
def location_address(self) -> str | None:
"""
Retrieves the full address from the document location URI.
Returns None when no location is set.
"""
return self.properties.location_address()

def location_path(self, nonexist_ok=True) -> Optional[str]:
def location_path(self, nonexist_ok=True) -> str | None:
"""
Retrieves a path that's resolved to a pathname in the local file system.
To obtain the original value of the "path" part in the location string
Expand All @@ -665,6 +670,7 @@ class AnnotationProperties(MmifObject, MutableMapping[str, T]):

:param mmif_obj: the JSON data that defines the properties
"""
_unnamed_attributes: dict

def __delitem__(self, key: str) -> None:
key = self._canonical(key)
Expand Down Expand Up @@ -718,8 +724,8 @@ def __setitem__(self, key: str, value) -> None:
DeprecationWarning)
super().__setitem__(canonical, value)

def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None,
parent: 'Optional[Annotation]' = None, *_) -> None:
def __init__(self, mmif_obj: bytes | str | dict | None = None,
parent: 'Annotation | None' = None, *_) -> None:
self.id: str = ''
# any individual at_type (subclassing this class) can have its own set of required attributes
self._required_attributes = ["id"]
Expand All @@ -745,7 +751,7 @@ def _deserialize(self, input_dict: dict) -> None:
"""
canonical_dict = {}
# canonical name -> the deprecated spelling it was read from
from_alias = {}
from_alias: dict = {}
for key, value in input_dict.items():
canonical = self._canonical(key)
if canonical in canonical_dict and (canonical != key or canonical in from_alias):
Expand All @@ -769,8 +775,8 @@ class DocumentProperties(AnnotationProperties):
:param mmif_obj: the JSON data that defines the properties
"""

def __init__(self, mmif_obj: Optional[Union[bytes, str, dict]] = None,
parent: 'Optional[Annotation]' = None, *_) -> None:
def __init__(self, mmif_obj: bytes | str | dict | None = None,
parent: 'Annotation | None' = None, *_) -> None:
self.mime: str = ''
# note the trailing underscore here. I wanted to use the name `location`
# for @property in this class and `Document` class, so had to use a diff
Expand Down Expand Up @@ -813,7 +819,7 @@ def text_value(self, s: str) -> None:
self.text.value = s

@property
def location(self) -> Optional[str]:
def location(self) -> str | None:
"""
``location`` property must be a legitimate URI. That is, should the document be a local file
then the file:// scheme must be used.
Expand All @@ -829,7 +835,7 @@ def location(self, location: str) -> None:
else:
self.location_ = location

def location_scheme(self) -> Optional[str]:
def location_scheme(self) -> str | None:
"""
Retrieves URI scheme of the document location.
Returns None when no location is set.
Expand All @@ -838,7 +844,7 @@ def location_scheme(self) -> Optional[str]:
return None
return urlparse(self.location).scheme

def location_address(self) -> Optional[str]:
def location_address(self) -> str | None:
"""
Retrieves the full address from the document location URI.
Returns None when no location is set.
Expand All @@ -851,7 +857,7 @@ def location_address(self) -> Optional[str]:
else:
return "".join((parsed_location.netloc, parsed_location.path))

def location_path(self) -> Optional[str]:
def location_path(self) -> str | None:
"""
.. deprecated:: 1.0.2
Will be removed in 2.0.0. Use :meth:`location_path_resolved`
Expand All @@ -863,7 +869,7 @@ def location_path(self) -> Optional[str]:
DeprecationWarning)
return self.location_path_resolved()

def location_path_resolved(self, nonexist_ok=True) -> Optional[str]:
def location_path_resolved(self, nonexist_ok=True) -> str | None:
"""
Retrieves only path name of the document location (hostname is ignored),
and then try to resolve the path name in the local file system.
Expand All @@ -887,7 +893,7 @@ def location_path_resolved(self, nonexist_ok=True) -> Optional[str]:
else:
return p

def location_path_literal(self) -> Optional[str]:
def location_path_literal(self) -> str | None:
"""
Retrieves only path name of the document location (hostname is ignored).
Returns None when no location is set.
Expand All @@ -899,7 +905,7 @@ def location_path_literal(self) -> Optional[str]:

class Text(MmifObject):

def __init__(self, text_obj: Optional[Union[bytes, str, dict]] = None, *_) -> None:
def __init__(self, text_obj: bytes | str | dict | None = None, *_) -> None:
self._value: str = ''
self._language: str = ''
self.disallow_additional_properties()
Expand Down
Loading
Loading