From c3fc2b8bc0a1507586a2159e0e53d6f7a3d5b18e Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 29 Aug 2024 11:18:14 +0200 Subject: [PATCH] Use modern type annotations --- docs/conf.py | 5 +++-- h5grove/content.py | 40 ++++++++++++++++++------------------- h5grove/encoders.py | 15 ++++++++------ h5grove/fastapi_utils.py | 8 +++++--- h5grove/flask_utils.py | 8 +++++--- h5grove/models.py | 31 ++++++++++++++-------------- h5grove/tornado_utils.py | 15 +++++++------- h5grove/utils.py | 31 +++++++++++++++------------- setup.cfg | 1 + test/base_test.py | 4 +++- test/conftest.py | 6 ++++-- test/test_benchmark_data.py | 4 +++- test/test_fastapi.py | 4 +++- test/test_flask.py | 4 +++- test/test_tornado.py | 4 +++- test/utils.py | 8 +++++--- 16 files changed, 106 insertions(+), 82 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c35f7bb..47e060b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,9 +10,10 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # +from __future__ import annotations + import os import sys -from typing import List sys.path.insert(0, os.path.abspath("..")) @@ -47,7 +48,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns: List[str] = [] +exclude_patterns: list[str] = [] # -- Options for HTML output ------------------------------------------------- diff --git a/h5grove/content.py b/h5grove/content.py index f5b520f..eaa9e4d 100644 --- a/h5grove/content.py +++ b/h5grove/content.py @@ -1,16 +1,14 @@ -import contextlib -from pathlib import Path +from __future__ import annotations +from collections.abc import Callable, Sequence from typing import ( Any, - Callable, - Dict, Generic, - Optional, - Sequence, TypeVar, - Union, cast, ) + +import contextlib +from pathlib import Path import h5py import numpy as np @@ -131,8 +129,8 @@ def __init__(self, path: str, h5py_entity: T): """Resolved h5py entity""" def attributes( - self, attr_keys: Optional[Sequence[str]] = None - ) -> Dict[str, AttributeMetadata]: + self, attr_keys: Sequence[str] | None = None + ) -> dict[str, AttributeMetadata]: """Attributes of the h5py entity. Can be filtered by keys.""" if attr_keys is None: return dict((*self._h5py_entity.attrs.items(),)) @@ -169,9 +167,9 @@ def metadata(self, depth=None) -> DatasetMetadata: def data( self, - selection: Selection = None, + selection: Selection | None = None, flatten: bool = False, - dtype: Optional[str] = "origin", + dtype: str | None = "origin", ): """Dataset data. @@ -189,7 +187,7 @@ def data( return result - def data_stats(self, selection: Selection = None) -> Stats: + def data_stats(self, selection: Selection | None = None) -> Stats: """Statistics on the data. Providing a selection will compute stats only on the selected slice. :param selection: NumPy-like indexing to define a selection as a slice @@ -254,7 +252,7 @@ def metadata(self, depth=None) -> DatatypeMetadata: def create_content( h5file: h5py.File, - path: Optional[str], + path: str | None, resolve_links: LinkResolution = LinkResolution.ONLY_VALID, ): """ @@ -293,11 +291,11 @@ def create_content( @contextlib.contextmanager def get_content_from_file( - filepath: Union[str, Path], - path: Optional[str], + filepath: str | Path, + path: str | None, create_error: Callable[[int, str], Exception], - resolve_links_arg: Optional[str] = LinkResolution.ONLY_VALID, - h5py_options: Dict[str, Any] = {}, + resolve_links_arg: str | None = LinkResolution.ONLY_VALID, + h5py_options: dict[str, Any] = {}, ): f = open_file_with_error_fallback(filepath, create_error, h5py_options) @@ -322,11 +320,11 @@ def get_content_from_file( @contextlib.contextmanager def get_list_of_paths( - filepath: Union[str, Path], - base_path: Optional[str], + filepath: str | Path, + base_path: str | None, create_error: Callable[[int, str], Exception], - resolve_links_arg: Optional[str] = LinkResolution.ONLY_VALID, - h5py_options: Dict[str, Any] = {}, + resolve_links_arg: str | None = LinkResolution.ONLY_VALID, + h5py_options: dict[str, Any] = {}, ): f = open_file_with_error_fallback(filepath, create_error, h5py_options) diff --git a/h5grove/encoders.py b/h5grove/encoders.py index 12a1b44..7bb2455 100644 --- a/h5grove/encoders.py +++ b/h5grove/encoders.py @@ -1,5 +1,8 @@ +from __future__ import annotations +from collections.abc import Callable +from typing import Any + import io -from typing import Any, Callable, Dict, Optional, Union import numpy as np import orjson import h5py @@ -17,7 +20,7 @@ def bin_encode(array: np.ndarray) -> bytes: return array.tobytes() -def orjson_default(o: Any) -> Union[list, float, str, None]: +def orjson_default(o: Any) -> list | float | str | None: """Converts Python objects to JSON-serializable objects. :raises TypeError: if the object is not supported.""" @@ -37,7 +40,7 @@ def orjson_default(o: Any) -> Union[list, float, str, None]: raise TypeError -def orjson_encode(content: Any, default: Optional[Callable] = None) -> bytes: +def orjson_encode(content: Any, default: Callable | None = None) -> bytes: """Encode in JSON using orjson. :param: content: Content to encode @@ -82,15 +85,15 @@ def tiff_encode(data: np.ndarray) -> bytes: class Response: content: bytes """ Encoded `content` as bytes """ - headers: Dict[str, str] + headers: dict[str, str] """ Associated headers """ - def __init__(self, content: bytes, headers: Dict[str, str]): + def __init__(self, content: bytes, headers: dict[str, str]): self.content = content self.headers = {**headers, "Content-Length": str(len(content))} -def encode(content: Any, encoding: Optional[str] = "json") -> Response: +def encode(content: Any, encoding: str | None = "json") -> Response: """Encode content in given encoding. Warning: Not all encodings supports all types of content. diff --git a/h5grove/fastapi_utils.py b/h5grove/fastapi_utils.py index 6271caf..27d0da1 100644 --- a/h5grove/fastapi_utils.py +++ b/h5grove/fastapi_utils.py @@ -1,9 +1,11 @@ """Helpers for usage with `FastAPI `_""" +from __future__ import annotations +from collections.abc import Callable + from fastapi import APIRouter, Depends, Response, Query, Request from fastapi.routing import APIRoute from pydantic_settings import BaseSettings -from typing import List, Optional, Union, Callable from .content import ( DatasetContent, @@ -46,7 +48,7 @@ async def custom_route_handler(request: Request) -> Response: class Settings(BaseSettings): - base_dir: Union[str, None] = None + base_dir: str | None = None settings = Settings() @@ -86,7 +88,7 @@ async def get_root(): async def get_attr( file: str = Depends(add_base_path), path: str = "/", - attr_keys: Optional[List[str]] = Query(default=None), + attr_keys: list[str] | None = Query(default=None), ): """`/attr/` endpoint handler""" with get_content_from_file(file, path, create_error) as content: diff --git a/h5grove/flask_utils.py b/h5grove/flask_utils.py index 68d3ece..dbfe0f2 100644 --- a/h5grove/flask_utils.py +++ b/h5grove/flask_utils.py @@ -1,10 +1,12 @@ """Helpers for usage with `Flask `_""" +from __future__ import annotations +from collections.abc import Callable, Mapping +from typing import Any + from werkzeug.exceptions import HTTPException from flask import Blueprint, current_app, request, Response, Request import os -from typing import Any, Callable, Mapping, Optional - from .content import ( DatasetContent, @@ -29,7 +31,7 @@ def make_encoded_response( - content, format_arg: Optional[str] = "json", status: Optional[int] = None + content, format_arg: str | None = "json", status: int | None = None ) -> Response: """Prepare flask Response according to format""" h5grove_response = encode(content, format_arg) diff --git a/h5grove/models.py b/h5grove/models.py index b506e3f..043a4ce 100644 --- a/h5grove/models.py +++ b/h5grove/models.py @@ -1,6 +1,7 @@ +from __future__ import annotations from enum import Enum -from typing import Dict, Tuple, Union, List -from typing_extensions import TypedDict, NotRequired, Optional +from typing import Union, Tuple, Dict, List +from typing_extensions import TypedDict, NotRequired import h5py H5pyEntity = Union[ @@ -22,6 +23,7 @@ class LinkResolution(str, Enum): StrDtype = Union[str, Dict[str, "StrDtype"]] # type: ignore # https://api.h5py.org/h5t.html +# Must use functional `TypedDict` syntax because of `class` key TypeMetadata = TypedDict( "TypeMetadata", { @@ -56,9 +58,10 @@ class SoftLinkMetadata(EntityMetadata): target_path: str -AttributeMetadata = TypedDict( - "AttributeMetadata", {"name": str, "shape": tuple, "type": TypeMetadata} -) +class AttributeMetadata(TypedDict): + name: str + shape: tuple + type: TypeMetadata class ResolvedEntityMetadata(EntityMetadata): @@ -80,14 +83,10 @@ class DatatypeMetadata(ResolvedEntityMetadata): type: TypeMetadata -Stats = TypedDict( - "Stats", - { - "strict_positive_min": Optional[Union[int, float]], - "positive_min": Optional[Union[int, float]], - "min": Optional[Union[int, float]], - "max": Optional[Union[int, float]], - "mean": Optional[Union[int, float]], - "std": Optional[Union[int, float]], - }, -) +class Stats(TypedDict): + strict_positive_min: Union[int, float, None] + positive_min: Union[int, float, None] + min: Union[int, float, None] + max: Union[int, float, None] + mean: Union[int, float, None] + std: Union[int, float, None] diff --git a/h5grove/tornado_utils.py b/h5grove/tornado_utils.py index 7d026bc..4256e8c 100644 --- a/h5grove/tornado_utils.py +++ b/h5grove/tornado_utils.py @@ -1,8 +1,9 @@ """Helpers for usage with `Tornado `_""" -import os -from typing import Any, Optional +from __future__ import annotations +from typing import Any +import os from tornado.web import HTTPError, MissingArgumentError, RequestHandler from .content import ( @@ -33,7 +34,7 @@ def create_error(status_code: int, message: str): class BaseHandler(RequestHandler): """Base class for h5grove handlers""" - def initialize(self, base_dir: str, allow_origin: Optional[str] = None) -> None: + def initialize(self, base_dir: str, allow_origin: str | None = None) -> None: self.base_dir = base_dir self.allow_origin = allow_origin @@ -57,7 +58,7 @@ def get(self): self.finish() def get_response( - self, full_file_path: str, path: Optional[str], resolve_links: Optional[str] + self, full_file_path: str, path: str | None, resolve_links: str | None ) -> Response: raise NotImplementedError @@ -82,7 +83,7 @@ def head(self): class ContentHandler(BaseHandler): def get_response( - self, full_file_path: str, path: Optional[str], resolve_links: Optional[str] + self, full_file_path: str, path: str | None, resolve_links: str | None ) -> Response: with get_content_from_file( full_file_path, path, create_error, resolve_links @@ -141,7 +142,7 @@ def get_content_response(self, content: EntityContent) -> Response: class PathsHandler(BaseHandler): def get_response( - self, full_file_path: str, path: Optional[str], resolve_links: Optional[str] + self, full_file_path: str, path: str | None, resolve_links: str | None ) -> Response: with get_list_of_paths( full_file_path, path, create_error, resolve_links @@ -150,7 +151,7 @@ def get_response( # TODO: Setting the return type raises mypy errors -def get_handlers(base_dir: Optional[str], allow_origin: Optional[str] = None): +def get_handlers(base_dir: str | None, allow_origin: str | None = None): """Build h5grove handlers (`/`, `/attr/`, `/data/`, `/meta/` and `/stats/`). :param base_dir: Base directory from which the HDF5 files will be served diff --git a/h5grove/utils.py b/h5grove/utils.py index 56ef031..e3094e9 100644 --- a/h5grove/utils.py +++ b/h5grove/utils.py @@ -1,9 +1,12 @@ +from __future__ import annotations +from collections.abc import Callable +from typing import Any, TypeVar + from pathlib import Path import h5py from h5py.version import version_tuple as h5py_version from os.path import basename import numpy as np -from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union from .models import ( H5pyEntity, @@ -89,7 +92,7 @@ def get_entity_from_file( return h5file[path] -def parse_slice(slice_str: str) -> Tuple[Union[slice, int], ...]: +def parse_slice(slice_str: str) -> tuple[slice | int, ...]: """ Parses a string containing a slice under NumPy format. @@ -108,7 +111,7 @@ def parse_slice(slice_str: str) -> Tuple[Union[slice, int], ...]: return tuple(parse_slice_member(s) for s in slice_members) -def parse_slice_member(slice_member: str) -> Union[slice, int]: +def parse_slice_member(slice_member: str) -> slice | int: if ":" not in slice_member: return int(slice_member) @@ -132,7 +135,7 @@ def parse_slice_member(slice_member: str) -> Union[slice, int]: raise TypeError(f"{slice_member} is not a valid slice") -def sorted_dict(*args: Tuple[str, Any]): +def sorted_dict(*args: tuple[str, Any]): return dict(sorted(args, key=lambda entry: entry[0])) @@ -229,7 +232,7 @@ def _sanitize_dtype(dtype: np.dtype) -> np.dtype: T = TypeVar("T", np.ndarray, np.number, np.bool_) -def convert(data: T, dtype: Optional[str] = "origin") -> T: +def convert(data: T, dtype: str | None = "origin") -> T: """Convert array or numpy scalar to given dtype query param :param data: nD array or scalar to convert @@ -251,7 +254,7 @@ def convert(data: T, dtype: Optional[str] = "origin") -> T: raise QueryArgumentError(f"Unsupported dtype {dtype}") -def is_numeric_data(data: Union[np.ndarray, np.number, np.bool_, bytes]) -> bool: +def is_numeric_data(data: np.ndarray | np.number | np.bool_ | bytes) -> bool: if not isinstance(data, (np.ndarray, np.number, np.bool_)): return False @@ -288,14 +291,14 @@ def get_array_stats(data: np.ndarray) -> Stats: } -def hdf_path_join(prefix: Union[str, None], suffix: str): +def hdf_path_join(prefix: str | None, suffix: str): if prefix is None or prefix == "/": return f"/{suffix}" return f'{prefix.rstrip("/")}/{suffix}' -def parse_bool_arg(query_arg: Union[str, None], fallback: bool) -> bool: +def parse_bool_arg(query_arg: str | None, fallback: bool) -> bool: if query_arg is None: return fallback @@ -303,7 +306,7 @@ def parse_bool_arg(query_arg: Union[str, None], fallback: bool) -> bool: def parse_link_resolution_arg( - raw_query_arg: Union[str, None], fallback: LinkResolution + raw_query_arg: str | None, fallback: LinkResolution ) -> LinkResolution: if raw_query_arg is None: return fallback @@ -342,7 +345,7 @@ def get_dataset_slice(dataset: h5py.Dataset, selection: Selection): def get_filters( dataset: h5py.Dataset, -) -> Optional[List[Dict[str, Union[int, str]]]]: +) -> list[dict[str, int | str]] | None: property_list = dataset.id.get_create_plist() n_filters = property_list.get_nfilters() @@ -353,8 +356,8 @@ def get_filters( def get_filter_info( - filter: Tuple[int, int, Tuple[int, ...], str] -) -> Dict[str, Union[int, str]]: + filter: tuple[int, int, tuple[int, ...], str] +) -> dict[str, int | str]: # https://api.h5py.org/h5p.html#h5py.h5p.PropDCID.get_filter (filter_id, _, _, name) = filter @@ -371,9 +374,9 @@ def stringify_dtype(dtype: np.dtype) -> StrDtype: def open_file_with_error_fallback( - filepath: Union[str, Path], + filepath: str | Path, create_error: Callable[[int, str], Exception], - h5py_options: Dict[str, Any] = {}, + h5py_options: dict[str, Any] = {}, ) -> h5py.File: try: f = h5py.File(filepath, "r", **h5py_options) diff --git a/setup.cfg b/setup.cfg index 14e7687..f2926d8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,7 @@ dev = black bump2version check-manifest + eval_type_backport flake8 h5grove[fastapi] h5grove[flask] diff --git a/test/base_test.py b/test/base_test.py index df6d60d..24f2225 100644 --- a/test/base_test.py +++ b/test/base_test.py @@ -1,8 +1,10 @@ """Base class for testing with different servers""" +from __future__ import annotations +from collections.abc import Generator + import os import stat -from typing import Generator from urllib.parse import urlencode import h5py diff --git a/test/conftest.py b/test/conftest.py index ce6c6dd..e031abb 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,10 +1,12 @@ +from __future__ import annotations +from collections.abc import Callable + import os import pathlib import socketserver import subprocess import sys import time -from typing import Callable, Optional from urllib.request import urlopen from urllib.error import HTTPError @@ -31,7 +33,7 @@ def _get_response(self, url: str, benchmark: Callable) -> Response: def get( self, url: str, - benchmark: Optional[Callable] = None, + benchmark: Callable | None = None, ) -> Response: """Request url and return retrieved response""" if benchmark is None: diff --git a/test/test_benchmark_data.py b/test/test_benchmark_data.py index ae75af1..213cd26 100644 --- a/test/test_benchmark_data.py +++ b/test/test_benchmark_data.py @@ -1,7 +1,9 @@ """Benchmark data requests with server apps in example/ folder""" +from __future__ import annotations +from collections.abc import Generator + import pathlib -from typing import Generator from urllib.parse import urlencode import h5py import numpy as np diff --git a/test/test_fastapi.py b/test/test_fastapi.py index 4fb7e46..71b7013 100644 --- a/test/test_fastapi.py +++ b/test/test_fastapi.py @@ -1,7 +1,9 @@ """Test fastapi_utils with fastapi testing""" +from __future__ import annotations +from collections.abc import Callable + import pathlib -from typing import Callable from fastapi import FastAPI from fastapi.testclient import TestClient import pytest diff --git a/test/test_flask.py b/test/test_flask.py index 75b4500..ddec322 100644 --- a/test/test_flask.py +++ b/test/test_flask.py @@ -1,7 +1,9 @@ """Test flask_utils blueprint with Flask testing""" +from __future__ import annotations +from collections.abc import Callable + import pathlib -from typing import Callable from flask import Flask import pytest diff --git a/test/test_tornado.py b/test/test_tornado.py index 628a2a8..7de8fc5 100644 --- a/test/test_tornado.py +++ b/test/test_tornado.py @@ -1,7 +1,9 @@ """Test tornado_utils using pytest-tornado""" +from __future__ import annotations +from collections.abc import Callable + import pathlib -from typing import Callable import pytest from tornado.httpclient import HTTPClientError import tornado.web diff --git a/test/utils.py b/test/utils.py index fcedcf2..124eb4d 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,7 +1,9 @@ +from __future__ import annotations +from typing import NamedTuple + import io import json import numpy as np -from typing import List, NamedTuple, Tuple import tifffile @@ -12,7 +14,7 @@ class Response(NamedTuple): """Return type of :meth:`get`""" status: int - headers: List[Tuple[str, str]] + headers: list[tuple[str, str]] content: bytes def find_header_value(self, key: str): @@ -55,7 +57,7 @@ def decode_array_response( response: Response, format: str, dtype: str, - shape: Tuple[int, ...], + shape: tuple[int, ...], ) -> np.ndarray: """Decode data array response content according to given information""" content_type = response.find_header_value("content-type")