diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31062030c..1df293f3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | diff --git a/src/monty/bisect.py b/src/monty/bisect.py index 6261d8c2b..5047a8870 100644 --- a/src/monty/bisect.py +++ b/src/monty/bisect.py @@ -10,10 +10,6 @@ from __future__ import annotations import bisect as bs -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Optional __author__ = "Matteo Giantomassi" __copyright__ = "Copyright 2013, The Materials Virtual Lab" @@ -23,7 +19,7 @@ __date__ = "11/09/14" -def index(a: list[float], x: float, atol: Optional[float] = None) -> int: +def index(a: list[float], x: float, atol: float | None = None) -> int: """Locate the leftmost value exactly equal to x.""" i = bs.bisect_left(a, x) if i != len(a): diff --git a/src/monty/dev.py b/src/monty/dev.py index cf468bce4..c7525864b 100644 --- a/src/monty/dev.py +++ b/src/monty/dev.py @@ -17,15 +17,15 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Callable, Optional, Type + from typing import Callable, Type logger = logging.getLogger(__name__) def deprecated( - replacement: Optional[Callable | str] = None, + replacement: Callable | str | None = None, message: str = "", - deadline: Optional[tuple[int, int, int]] = None, + deadline: tuple[int, int, int] | None = None, category: Type[Warning] = FutureWarning, ) -> Callable: """ @@ -34,7 +34,7 @@ def deprecated( Args: replacement (Callable | str): A replacement class or function. message (str): A warning message to be displayed. - deadline (Optional[tuple[int, int, int]]): Optional deadline for removal + deadline (tuple[int, int, int] | None): Optional deadline for removal of the old function/class, in format (yyyy, MM, dd). A CI warning would be raised after this date if is running in code owner' repo. category (Warning): Choose the category of the warning to issue. Defaults diff --git a/src/monty/functools.py b/src/monty/functools.py index 969f0d506..13c188dac 100644 --- a/src/monty/functools.py +++ b/src/monty/functools.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Union + from typing import Any, Callable class _HashedSeq(list): # pylint: disable=C0205 @@ -130,7 +130,7 @@ def invalidate(cls, inst: object, name: str) -> None: def return_if_raise( - exception_tuple: Union[list, tuple], retval_if_exc: Any, disabled: bool = False + exception_tuple: list | tuple, retval_if_exc: Any, disabled: bool = False ) -> Any: """ Decorator for functions, methods or properties. Execute the callable in a diff --git a/src/monty/io.py b/src/monty/io.py index ccd24189e..b22ea6231 100644 --- a/src/monty/io.py +++ b/src/monty/io.py @@ -19,14 +19,11 @@ from typing import TYPE_CHECKING, Literal, cast if TYPE_CHECKING: - from typing import IO, Any, Iterator, Union - - -class EncodingWarning(Warning): ... # Added in Python 3.10 + from typing import IO, Any, Iterator def zopen( - filename: Union[str, Path], + filename: str | Path, /, mode: str | None = None, **kwargs: Any, @@ -35,7 +32,7 @@ def zopen( This function wraps around `[bz2/gzip/lzma].open` and `open` to deal intelligently with compressed or uncompressed files. Supports context manager: - `with zopen(filename, mode="rt", ...)`. + `with zopen(filename, mode="rt", ...)` Important Notes: - Default `mode` should not be used, and would not be allow @@ -43,11 +40,12 @@ def zopen( - Always explicitly specify binary/text in `mode`, i.e. always pass `t` or `b` in `mode`, implicit binary/text mode would not be allow in future versions. - - Always provide an explicit `encoding` in text mode. + - Always provide an explicit `encoding` in text mode, it would + be set to UTF-8 by default otherwise. Args: filename (str | Path): The file to open. - mode (str): The mode in which the file is opened, you MUST + mode (str): The mode in which the file is opened, you should explicitly specify "b" for binary or "t" for text. **kwargs: Additional keyword arguments to pass to `open`. @@ -79,14 +77,16 @@ def zopen( stacklevel=2, ) - # Warn against default `encoding` in text mode + # Warn against default `encoding` in text mode if + # `PYTHONWARNDEFAULTENCODING` environment variable is set (PEP 597) if "t" in mode and kwargs.get("encoding", None) is None: - warnings.warn( - "We strongly encourage explicit `encoding`, " - "and we would use UTF-8 by default as per PEP 686", - category=EncodingWarning, - stacklevel=2, - ) + if os.getenv("PYTHONWARNDEFAULTENCODING", False): + warnings.warn( + "We strongly encourage explicit `encoding`, " + "and we would use UTF-8 by default as per PEP 686", + category=EncodingWarning, + stacklevel=2, + ) kwargs["encoding"] = "utf-8" _name, ext = os.path.splitext(filename) @@ -141,7 +141,7 @@ def _get_line_ending( If file is empty, "\n" would be used as default. """ if isinstance(file, (str, Path)): - with zopen(file, "rb") as f: + with zopen(file, mode="rb") as f: first_line = f.readline() elif isinstance(file, io.TextIOWrapper): first_line = file.buffer.readline() # type: ignore[attr-defined] @@ -169,7 +169,7 @@ def _get_line_ending( def reverse_readfile( - filename: Union[str, Path], + filename: str | Path, ) -> Iterator[str]: """ A much faster reverse read of file by using Python's mmap to generate a @@ -187,7 +187,7 @@ def reverse_readfile( l_end = _get_line_ending(filename) len_l_end = len(l_end) - with zopen(filename, "rb") as file: + with zopen(filename, mode="rb") as file: if isinstance(file, (gzip.GzipFile, bz2.BZ2File)): for line in reversed(file.readlines()): # "readlines" would keep the line end character diff --git a/src/monty/os/__init__.py b/src/monty/os/__init__.py index 0158cec7a..24539971d 100644 --- a/src/monty/os/__init__.py +++ b/src/monty/os/__init__.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from pathlib import Path - from typing import Generator, Union + from typing import Generator __author__ = "Shyue Ping Ong" __copyright__ = "Copyright 2013, The Materials Project" @@ -22,7 +22,7 @@ @contextmanager -def cd(path: Union[str, Path]) -> Generator: +def cd(path: str | Path) -> Generator: """ A Fabric-inspired cd context that temporarily changes directory for performing some tasks, and returns to the original working directory @@ -42,7 +42,7 @@ def cd(path: Union[str, Path]) -> Generator: os.chdir(cwd) -def makedirs_p(path: Union[str, Path], **kwargs) -> None: +def makedirs_p(path: str | Path, **kwargs) -> None: """ Wrapper for os.makedirs that does not raise an exception if the directory already exists, in the fashion of "mkdir -p" command. The check is diff --git a/src/monty/os/path.py b/src/monty/os/path.py index fa293a90f..5b3c0cb4e 100644 --- a/src/monty/os/path.py +++ b/src/monty/os/path.py @@ -12,7 +12,7 @@ from monty.string import list_strings if TYPE_CHECKING: - from typing import Callable, Literal, Optional, Union + from typing import Callable, Literal def zpath(filename: str | Path) -> str: @@ -41,9 +41,9 @@ def zpath(filename: str | Path) -> str: def find_exts( top: str, - exts: Union[str, list[str]], - exclude_dirs: Optional[str] = None, - include_dirs: Optional[str] = None, + exts: str | list[str], + exclude_dirs: str | None = None, + include_dirs: str | None = None, match_mode: Literal["basename", "abspath"] = "basename", ) -> list[str]: """ diff --git a/src/monty/re.py b/src/monty/re.py index 76152ff13..feeaed248 100644 --- a/src/monty/re.py +++ b/src/monty/re.py @@ -49,7 +49,7 @@ def regrep( gen = ( reverse_readfile(filename) if reverse - else zopen(filename, "rt", encoding="utf-8") + else zopen(filename, mode="rt", encoding="utf-8") ) for i, line in enumerate(gen): for k, p in compiled.items(): diff --git a/src/monty/serialization.py b/src/monty/serialization.py index 13f3fb004..ff2a53de2 100644 --- a/src/monty/serialization.py +++ b/src/monty/serialization.py @@ -22,10 +22,15 @@ if TYPE_CHECKING: from pathlib import Path - from typing import Any, Optional, TextIO, Union + from typing import Any, Literal, TextIO -def loadfn(fn: Union[str, Path], *args, fmt: Optional[str] = None, **kwargs) -> Any: +def loadfn( + fn: str | Path, + *args, + fmt: Literal["json", "yaml", "mpk"] | None = None, + **kwargs, +) -> Any: """ Loads json/yaml/msgpack directly from a filename instead of a File-like object. File may also be a BZ2 (".BZ2") or GZIP (".GZ", ".Z") @@ -39,9 +44,8 @@ def loadfn(fn: Union[str, Path], *args, fmt: Optional[str] = None, **kwargs) -> Args: fn (str/Path): filename or pathlib.Path. *args: Any of the args supported by json/yaml.load. - fmt (string): If specified, the fmt specified would be used instead - of autodetection from filename. Supported formats right now are - "json", "yaml" or "mpk". + fmt ("json" | "yaml" | "mpk"): If specified, the fmt specified would + be used instead of autodetection from filename. **kwargs: Any of the kwargs supported by json/yaml.load. Returns: @@ -64,10 +68,10 @@ def loadfn(fn: Union[str, Path], *args, fmt: Optional[str] = None, **kwargs) -> ) if "object_hook" not in kwargs: kwargs["object_hook"] = object_hook - with zopen(fn, "rb") as fp: + with zopen(fn, mode="rb") as fp: return msgpack.load(fp, *args, **kwargs) # pylint: disable=E1101 else: - with zopen(fn, "rt", encoding="utf-8") as fp: + with zopen(fn, mode="rt", encoding="utf-8") as fp: if fmt == "yaml": if YAML is None: raise RuntimeError("Loading of YAML files requires ruamel.yaml.") @@ -81,7 +85,13 @@ def loadfn(fn: Union[str, Path], *args, fmt: Optional[str] = None, **kwargs) -> raise TypeError(f"Invalid format: {fmt}") -def dumpfn(obj: object, fn: Union[str, Path], *args, fmt=None, **kwargs) -> None: +def dumpfn( + obj: object, + fn: str | Path, + *args, + fmt: Literal["json", "yaml", "mpk"] | None = None, + **kwargs, +) -> None: """ Dump to a json/yaml directly by filename instead of a File-like object. File may also be a BZ2 (".BZ2") or GZIP (".GZ", ".Z") @@ -95,6 +105,8 @@ def dumpfn(obj: object, fn: Union[str, Path], *args, fmt=None, **kwargs) -> None Args: obj (object): Object to dump. fn (str/Path): filename or pathlib.Path. + fmt ("json" | "yaml" | "mpk"): If specified, the fmt specified would + be used instead of autodetection from filename. *args: Any of the args supported by json/yaml.dump. **kwargs: Any of the kwargs supported by json/yaml.dump. @@ -117,10 +129,10 @@ def dumpfn(obj: object, fn: Union[str, Path], *args, fmt=None, **kwargs) -> None ) if "default" not in kwargs: kwargs["default"] = default - with zopen(fn, "wb") as fp: + with zopen(fn, mode="wb") as fp: msgpack.dump(obj, fp, *args, **kwargs) # pylint: disable=E1101 else: - with zopen(fn, "wt", encoding="utf-8") as fp: + with zopen(fn, mode="wt", encoding="utf-8") as fp: fp = cast(TextIO, fp) if fmt == "yaml": diff --git a/src/monty/shutil.py b/src/monty/shutil.py index 98047f576..5dc1c8c07 100644 --- a/src/monty/shutil.py +++ b/src/monty/shutil.py @@ -12,7 +12,7 @@ from monty.io import zopen if TYPE_CHECKING: - from typing import Literal, Optional + from typing import Literal def copy_r(src: str | Path, dst: str | Path) -> None: @@ -76,7 +76,7 @@ def gzip_dir(path: str | Path, compresslevel: int = 6) -> None: def compress_file( filepath: str | Path, compression: Literal["gz", "bz2"] = "gz", - target_dir: Optional[str | Path] = None, + target_dir: str | Path | None = None, ) -> None: """ Compresses a file with the correct extension. Functions like standard @@ -104,7 +104,7 @@ def compress_file( else: compressed_file = f"{str(filepath)}.{compression}" - with open(filepath, "rb") as f_in, zopen(compressed_file, "wb") as f_out: + with open(filepath, "rb") as f_in, zopen(compressed_file, mode="wb") as f_out: f_out.writelines(f_in) os.remove(filepath) @@ -130,7 +130,7 @@ def compress_dir(path: str | Path, compression: Literal["gz", "bz2"] = "gz") -> def decompress_file( - filepath: str | Path, target_dir: Optional[str | Path] = None + filepath: str | Path, target_dir: str | Path | None = None ) -> str | None: """ Decompresses a file with the correct extension. Automatically detects @@ -157,7 +157,7 @@ def decompress_file( else: decompressed_file = str(filepath).removesuffix(file_ext) - with zopen(filepath, "rb") as f_in, open(decompressed_file, "wb") as f_out: + with zopen(filepath, mode="rb") as f_in, open(decompressed_file, "wb") as f_out: f_out.writelines(f_in) os.remove(filepath) diff --git a/src/monty/string.py b/src/monty/string.py index 1a56debca..72a6b42e0 100644 --- a/src/monty/string.py +++ b/src/monty/string.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Iterable, cast if TYPE_CHECKING: - from typing import Any, Union + from typing import Any def remove_non_ascii(s: str) -> str: @@ -34,7 +34,7 @@ def is_string(s: Any) -> bool: return False -def list_strings(arg: Union[str, Iterable[str]]) -> list[str]: +def list_strings(arg: str | Iterable[str]) -> list[str]: """ Always return a list of strings, given a string or list of strings as input. diff --git a/src/monty/subprocess.py b/src/monty/subprocess.py index 104261c2e..e17f95c55 100644 --- a/src/monty/subprocess.py +++ b/src/monty/subprocess.py @@ -13,8 +13,6 @@ from monty.string import is_string if TYPE_CHECKING: - from typing import Optional - from typing_extensions import Self __author__ = "Matteo Giantomass" @@ -63,7 +61,7 @@ def __init__(self, command: str): def __str__(self): return f"command: {self.command}, retcode: {self.retcode}" - def run(self, timeout: Optional[float] = None, **kwargs) -> Self: + def run(self, timeout: float | None = None, **kwargs) -> Self: """ Run a command in a separated thread and wait timeout seconds. kwargs are keyword arguments passed to Popen. diff --git a/src/monty/tempfile.py b/src/monty/tempfile.py index 979dc5126..f5ac9556e 100644 --- a/src/monty/tempfile.py +++ b/src/monty/tempfile.py @@ -11,9 +11,6 @@ from monty.shutil import copy_r, gzip_dir, remove -if TYPE_CHECKING: - from typing import Union - class ScratchDir: """ @@ -42,7 +39,7 @@ class ScratchDir: def __init__( self, - rootpath: Union[str, Path, None], + rootpath: str | Path | None, create_symbolic_link: bool = False, copy_from_current_on_enter: bool = False, copy_to_current_on_exit: bool = False, diff --git a/tests/test_io.py b/tests/test_io.py index f4422bf2a..5617e847a 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -9,7 +9,6 @@ import pytest from monty.io import ( - EncodingWarning, FileLock, FileLockException, _get_line_ending, @@ -427,16 +426,19 @@ def test_lzw_files(self): # Cannot decompress a real LZW file with ( pytest.raises(gzip.BadGzipFile, match="Not a gzipped file"), + pytest.warns(FutureWarning, match="compress LZW-compressed files"), zopen(f"{TEST_DIR}/real_lzw_file.txt.Z", "rt", encoding="utf-8") as f, ): f.read() @pytest.mark.parametrize("extension", [".txt", ".bz2", ".gz", ".xz", ".lzma"]) - def test_warnings(self, extension): + def test_warnings(self, extension, monkeypatch): filename = f"test_warning{extension}" content = "Test warning" with ScratchDir("."): + monkeypatch.setenv("PYTHONWARNDEFAULTENCODING", "1") + # Default `encoding` warning with ( pytest.warns(EncodingWarning, match="use UTF-8 by default"), @@ -444,6 +446,19 @@ def test_warnings(self, extension): ): f.write(content) + # No encoding warning if `PYTHONWARNDEFAULTENCODING` not set + monkeypatch.delenv("PYTHONWARNDEFAULTENCODING", raising=False) + + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + "We strongly encourage explicit `encoding`", + EncodingWarning, + ) + + with zopen(filename, "wt") as f: + f.write(content) + # Implicit text/binary `mode` warning warnings.filterwarnings( "ignore", category=EncodingWarning, message="argument not specified"