diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..66e6c5c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)", + "Bash(pytest:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git status:*)", + "Bash(git show:*)" + ] + } +} diff --git a/docs/history.rst b/docs/history.rst index 40888b6..8c3f4f1 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -2,6 +2,61 @@ History ======= +5.8.0 (2026-04-21) +------------------ + +Breaking changes worth highlighting: + +* ``ArtifactInfo.from_basename()`` raises ``ValueError`` on unparseable input (was ``None``). + Callers that depended on the ``None`` branch need to catch ``ValueError`` instead. + +* ``PythonSpec.version`` is never ``None`` anymore. Code checking ``spec.version is None`` should switch to + ``not spec.is_valid`` (or ``not spec.version.is_valid``). + +* ``cached_property.to_dict(cls)`` no longer returns ``None`` for a class — the method now requires an + instance. If any caller passed the class as a "no cached values" sentinel, they need to adjust. + +* ``PythonSpec.from_object(Version("foo"))`` used to return a spec with an invalid version; now returns + ``None``. + +Non-breaking additions: + +* ``runez.OptionalColor``, ``Traceable``, ``Version.required_from_object()``, ``PythonSpec.is_valid`` + +* ``abort()`` overloads — purely a typing improvement, runtime behavior unchanged + +More details: + +* Stronger and more consistent type annotations across the library (``pyenv``, ``system``, ``logsetup``, + ``colors``, ``render``, ``serialize``); modernized to ``X | None`` / ``list[X]`` style throughout + +* Added ``runez.OptionalColor`` type alias (``Callable | str | None``) for parameters accepting a color name, + color callable, or ``None`` + +* Added ``Traceable`` protocol in ``runez.logsetup`` for objects exposing a ``.trace(message)`` method + +* ``abort()`` now has proper ``@overload``-ed signatures: ``NoReturn`` when fatal, otherwise returns + ``return_value`` with a preserved generic type + +* ``Version.required_from_object()``: new classmethod that raises ``ValueError`` when a version can't be + determined (companion to ``Version.from_object()`` which returns ``None``) + +* ``Version.from_object()``, ``PythonSpec.from_object()`` and ``PythonSpec.from_text()`` now share a + consistent contract: a returned object is guaranteed to be valid, otherwise ``None`` is returned + +* ``PythonSpec.is_valid`` property added as a shortcut for ``spec.version.is_valid`` + +* ``PythonSpec.__init__`` now accepts ``Version | str`` (tighter input typing) + +* ``ArtifactInfo.from_basename()`` return type narrowed to ``ArtifactInfo`` (no longer ``| None``) + +* ``cached_property.to_dict()``: ``target`` must now be an instance (not a class, not ``None``); always + returns a ``dict`` + +* ``cached_property.properties()`` and ``cached_property._walk_properties()``: annotated as + ``Iterator[...]`` generators + + 5.7.1 (2026-04-03) ------------------ diff --git a/pyproject.toml b/pyproject.toml index 2ab5e30..6336762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ max-complexity = 20 "src/runez/conftest.py" = ["S101"] "src/runez/http.py" = ["S101"] "src/runez/serialize.py" = ["S101"] -"tests/**" = ["ARG001", "S"] +"tests/**" = ["ARG001", "S", "TRY003"] [tool.ruff.lint.pydocstyle] convention = "numpy" diff --git a/src/runez/__init__.py b/src/runez/__init__.py index 6b106a4..75bce27 100644 --- a/src/runez/__init__.py +++ b/src/runez/__init__.py @@ -17,7 +17,7 @@ from runez.logsetup import LogManager as log, ProgressBar from runez.program import check_pid, is_executable, make_executable, run, shell, which from runez.serialize import from_json, json_sanitized, read_json, represented_json, save_json, Serializable -from runez.system import abort, abort_if, cached_property, uncolored, Undefined, UNSET, wcswidth +from runez.system import abort, abort_if, cached_property, OptionalColor, uncolored, Undefined, UNSET, wcswidth from runez.system import Anchored, CaptureOutput, CurrentFolder, OverrideDryrun, TempArgv, TrackedOutput from runez.system import capped, decode, DEV, flattened, joined, quoted, resolved_path, short, stringified, SYS_INFO from runez.system import first_line, get_version, is_basetype, is_iterable, ltattr @@ -38,7 +38,7 @@ "log", "ProgressBar", "check_pid", "is_executable", "make_executable", "run", "shell", "which", "from_json", "json_sanitized", "read_json", "represented_json", "save_json", "Serializable", - "abort", "abort_if", "cached_property", "uncolored", "Undefined", "UNSET", "wcswidth", + "abort", "abort_if", "cached_property", "OptionalColor", "uncolored", "Undefined", "UNSET", "wcswidth", "Anchored", "CaptureOutput", "CurrentFolder", "OverrideDryrun", "TempArgv", "TrackedOutput", "capped", "decode", "DEV", "flattened", "joined", "quoted", "resolved_path", "short", "stringified", "SYS_INFO", "first_line", "get_version", "is_basetype", "is_iterable", "ltattr" diff --git a/src/runez/colors/__init__.py b/src/runez/colors/__init__.py index 8d22121..2241b81 100644 --- a/src/runez/colors/__init__.py +++ b/src/runez/colors/__init__.py @@ -7,7 +7,9 @@ 'hello' """ -from runez.system import DEV, short, Slotted, stringified, SYS_INFO, uncolored, UNSET +from typing import Callable + +from runez.system import DEV, OptionalColor, short, Slotted, stringified, SYS_INFO, uncolored, UNSET class ActivateColors: @@ -76,10 +78,10 @@ class ColorManager: style: "NamedStyles" @classmethod - def cast_color(cls, name, source=None, strict=True): + def cast_color(cls, name: OptionalColor, source=None, strict=True) -> Callable | None: """ Args: - name (str | callable): Color name to find (returned as-is if already a callable) + name: Color name to find (returned as-is if already a callable) source (NamedRenderables | None): Restrict to given source (otherwise searched in fg and style) strict (bool): If True, raise ValueError if color could not be found @@ -110,11 +112,11 @@ def cast_style(cls, name, strict=True): return cls.cast_color(name, source=cls.style, strict=strict) @classmethod - def colored(cls, text, color, is_coloring=UNSET): + def colored(cls, text, color: OptionalColor, is_coloring=UNSET): """ Args: text: Text to color. - color (str | callable | None): Color to use + color: Color to use is_coloring (bool | runez.Undefined): If provided, overrides current coloring state Returns: diff --git a/src/runez/colors/named.py b/src/runez/colors/named.py index eef4866..73a39f5 100644 --- a/src/runez/colors/named.py +++ b/src/runez/colors/named.py @@ -2,78 +2,78 @@ # Colors -def black(text, size=None): +def black(text, size=None) -> str: return ColorManager.fg.black(text, size=size) -def blue(text, size=None): +def blue(text, size=None) -> str: return ColorManager.fg.blue(text, size=size) -def brown(text, size=None): +def brown(text, size=None) -> str: return ColorManager.fg.brown(text, size=size) -def gray(text, size=None): +def gray(text, size=None) -> str: return ColorManager.fg.gray(text, size=size) -def green(text, size=None): +def green(text, size=None) -> str: return ColorManager.fg.green(text, size=size) -def orange(text, size=None): +def orange(text, size=None) -> str: return ColorManager.fg.orange(text, size=size) -def plain(text, size=None): +def plain(text, size=None) -> str: return ColorManager.fg.plain(text, size=size) -def purple(text, size=None): +def purple(text, size=None) -> str: return ColorManager.fg.purple(text, size=size) -def red(text, size=None): +def red(text, size=None) -> str: return ColorManager.fg.red(text, size=size) -def teal(text, size=None): +def teal(text, size=None) -> str: return ColorManager.fg.teal(text, size=size) -def white(text, size=None): +def white(text, size=None) -> str: return ColorManager.fg.white(text, size=size) -def yellow(text, size=None): +def yellow(text, size=None) -> str: return ColorManager.fg.yellow(text, size=size) # Styles -def blink(text, size=None): +def blink(text, size=None) -> str: return ColorManager.style.blink(text, size=size) -def bold(text, size=None): +def bold(text, size=None) -> str: return ColorManager.style.bold(text, size=size) -def dim(text, size=None): +def dim(text, size=None) -> str: return ColorManager.style.dim(text, size=size) -def invert(text, size=None): +def invert(text, size=None) -> str: return ColorManager.style.invert(text, size=size) -def italic(text, size=None): +def italic(text, size=None) -> str: return ColorManager.style.italic(text, size=size) -def strikethrough(text, size=None): +def strikethrough(text, size=None) -> str: return ColorManager.style.strikethrough(text, size=size) -def underline(text, size=None): +def underline(text, size=None) -> str: return ColorManager.style.underline(text, size=size) diff --git a/src/runez/logsetup.py b/src/runez/logsetup.py index 2959e75..6654fef 100644 --- a/src/runez/logsetup.py +++ b/src/runez/logsetup.py @@ -12,7 +12,7 @@ import threading import time from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler -from typing import Callable, List, Optional +from typing import Callable, Protocol from runez.ascii import AsciiAnimation from runez.convert import to_bytesize, to_int @@ -25,6 +25,7 @@ DEV, find_caller, flattened, + OptionalColor, quoted, short, Slotted, @@ -149,7 +150,7 @@ def _remove_parent(self, parent): class _SpinnerComponent: - def __init__(self, fps, source, color, adapter=None): + def __init__(self, fps, source, color: OptionalColor, adapter=None): self.adapter = adapter self.source = source self.color = color @@ -187,15 +188,23 @@ def update_text(self, ts): class _SpinnerState: - def __init__(self, parent, frames, max_columns, message_color, progress_color, spinner_color): + def __init__( + self, + parent, + frames, + max_columns, + message_color: OptionalColor, + progress_color: OptionalColor, + spinner_color: OptionalColor, + ): """ Args: parent (ProgressSpinner): Parent object frames (AsciiFrames): Frames to use for spinner max_columns (int | None): Optional max number of columns to use - message_color (str | callable | None): Optional color to use for the message - progress_color (callable | None): Optional color to use for the spinner - spinner_color (callable | None): Optional color to use for the spinner + message_color: Optional color to use for the message + progress_color: Optional color to use for the progress bar + spinner_color: Optional color to use for the spinner """ self.columns = SYS_INFO.terminal.columns - 2 if max_columns and max_columns > 0: @@ -251,15 +260,22 @@ def show(self, message): with self._lock: self._msg_show = message - def start(self, frames=UNSET, max_columns=140, message_color="dim", progress_color="teal", spinner_color=None): + def start( + self, + frames=UNSET, + max_columns=140, + message_color: OptionalColor = "dim", + progress_color: OptionalColor = "teal", + spinner_color: OptionalColor = None, + ): """Start a background thread to handle spinner, if stderr is a tty Args: frames (AsciiFrames | callable | str | None): Frames to use for spinner animation max_columns (int | None): Maximum number of terminal columns to use for progress line - message_color (str | callable | None): Optional color to use for the message part - progress_color (callable | None): Optional color to use for the progress bar - spinner_color (callable | None): Optional color to use for the animated spinner + message_color: Optional color to use for the message part + progress_color: Optional color to use for the progress bar + spinner_color: Optional color to use for the animated spinner """ with self._lock: if self._thread is None: @@ -410,6 +426,14 @@ def _run(self): self.stop() +class Traceable(Protocol): + """Anything that can receive trace messages — typically a `TraceHandler`, but any object with a + compatible `.trace(message)` method can be plugged into `LogManager.tracer`. + """ + + def trace(self, message: str) -> None: ... + + class TraceHandler: """ Allows to optionally provide trace logging, typically activated by an env var, like: @@ -598,7 +622,7 @@ class Timeit: function_name: str | None = None - def __init__(self, function=None, color="bold", logger=UNSET, fmt="{function} took {elapsed}"): + def __init__(self, function=None, color: OptionalColor = "bold", logger=UNSET, fmt="{function} took {elapsed}"): self.__func__ = None self.start_time: float = 0 self.color = color @@ -706,12 +730,12 @@ class LogManager: # Below fields should be read-only for outside users, do not modify these debug = False - console_handler: Optional[logging.StreamHandler] = None - file_handler: Optional[logging.FileHandler] = None # File we're currently logging to (if any) - handlers: Optional[List[logging.Handler]] = None - tracer: Optional[TraceHandler] = None - used_formats: Optional[str] = None - faulthandler_signum: Optional[int] = None + console_handler: logging.StreamHandler | None = None + file_handler: logging.FileHandler | None = None # File we're currently logging to (if any) + handlers: list[logging.Handler] | None = None + tracer: Traceable | None = None + used_formats: str | None = None + faulthandler_signum: int | None = None trace_env_var = "TRACE_DEBUG" # Convenience decorator/context logging how long a function or section of code took to run @@ -719,7 +743,7 @@ class LogManager: _lock = threading.RLock() _logging_snapshot = LoggingSnapshot() - _progress_handler: Optional[ProgressHandler] = None + _progress_handler: ProgressHandler | None = None @classmethod def set_debug(cls, debug): @@ -953,12 +977,15 @@ def override_spec(cls, **settings): cls.spec.set(**settings) @classmethod - def enable_trace(cls, spec, prefix: str | None = ":: ", stream=UNSET): + def enable_trace(cls, spec, prefix: str | None = ":: ", stream=UNSET) -> Traceable | None: """ Args: spec (str | bool | None): If string given, enable tracing when corresponding env var is set to a non-empty value prefix: Prefix to use for trace messages stream: Where to trace (by default: current 'console_stream' if configured, otherwise sys.stderr) + + Returns: + Prior tracer (if any), so caller can restore it """ prior = cls.tracer if spec is not UNSET: diff --git a/src/runez/pyenv.py b/src/runez/pyenv.py index f25028b..3057134 100644 --- a/src/runez/pyenv.py +++ b/src/runez/pyenv.py @@ -4,11 +4,11 @@ import os import re from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar from runez.file import ls_dir from runez.program import is_executable, run -from runez.system import _R, cached_property, flattened, joined, ltattr, resolved_path, short, UNSET +from runez.system import _R, cached_property, flattened, joined, ltattr, OptionalColor, resolved_path, short, UNSET CPYTHON = "cpython" RX_PYTHON_BASENAME = re.compile(r"^python(\d(\.\d+)?)?(t?)$") @@ -54,16 +54,16 @@ def __init__( self.size = size @classmethod - def from_basename(cls, basename, source=None, last_modified=None, size=None): + def from_basename(cls, basename: str, source=None, last_modified=None, size=None) -> ArtifactInfo: """ Args: - basename (str): Basename to parse + basename: Basename to parse source: Optional arbitrary object to track provenance of ArtifactInfo last_modified (datetime.datetime | None): Timestamp when artifact was last modified, if available size (int | None): Size in bytes of artifact, if available Returns: - (ArtifactInfo | None): Parsed artifact info, if any + Parsed artifact info. Raises ValueError if `basename` is not a recognizable sdist/wheel name. """ is_wheel = False wheel_build_number = tags = None @@ -71,7 +71,7 @@ def from_basename(cls, basename, source=None, last_modified=None, size=None): if not m: m = PypiStd.RX_WHEEL.match(basename) if not m: - return None + raise ValueError("Can't parse artifact info from basename: %r" % basename) wheel_build_number = m.group(4) tags = m.group(5) @@ -152,16 +152,21 @@ class PythonSpec: Examples: cpython:3, cpython:3.13, pypy:3.13 """ - def __init__(self, family, version, is_min_spec=False, freethreading=False): + version: Version + + def __init__(self, family: str, version: Version | str, is_min_spec=False, freethreading=False): """ Args: - family (str): Python family (cpython, conda, pypi, ...) - version (Version | str): Desired version + family: Python family (cpython, conda, pypi, ...) + version: Desired version is_min_spec (bool): If True, match installations that are at least at `version`, e.g. cpython:3.10+ freethreading (bool): Whether this is a freethreaded version """ + if not isinstance(version, Version): + version = Version(version) + self.family = family - self.version = Version.from_object(version) + self.version = version self.canonical = "%s:%s%s%s" % (family, version, "t" if freethreading else "", "+" if is_min_spec else "") self.is_min_spec = is_min_spec self.freethreading = freethreading @@ -181,22 +186,22 @@ def __lt__(self, other): def satisfies(self, other): """Does this spec satisfy 'other'?""" if isinstance(other, PythonSpec) and self.family == other.family and self.freethreading == other.freethreading: - if other.is_min_spec and other.version is not None: + if other.is_min_spec: return self.version >= other.version return self.canonical.startswith(other.canonical) - def represented(self, color=None, compact=CPYTHON): + def represented(self, color: OptionalColor = None, compact: str | list | set | tuple | bool | None = CPYTHON) -> str: """ Args: - color (callable | None): Optional color to use - compact (str | list | set | tuple | bool | None): Show version only, if `self.family` is mentioned in `compact` + color: Optional color to use + compact: Show version only, if `self.family` is mentioned in `compact` Returns: (str): Textual representation of this spec """ text = self.canonical - if compact and self.version is not None and (compact is True or self.family in compact): + if compact and (compact is True or self.family in compact): text = self.version.text if self.freethreading: text += "t" @@ -207,22 +212,22 @@ def represented(self, color=None, compact=CPYTHON): return _R.colored(text, color) @classmethod - def from_text(cls, text): + def from_text(cls, text: str) -> PythonSpec | None: """ Args: text (str | None): Text to be converted into a PythonSpec() if possible, eg: 3.10, py310, python3.10, conda:3.10 Returns: - (PythonSpec | None): Parsed spec from given object, if valid + Parsed spec from given text; a returned PythonSpec is guaranteed to be valid (`is_valid` is True), + otherwise None is returned. """ m = re.match(r"^(py|python|)(?P\d+(\.\d+(.\w+?)*)?)?(?Pt)?(?P\+?)$", text) if m: version = Version.from_tox_like(m.group("version"), default="3") - return ( - cls(CPYTHON, version, is_min_spec=bool(m.group("min_spec")), freethreading=bool(m.group("freethreading"))) - if version - else None - ) + if version is not None and version.is_valid: + return cls(CPYTHON, version, is_min_spec=bool(m.group("min_spec")), freethreading=bool(m.group("freethreading"))) + + return None m = re.match(r"^(?Pcpython|conda|pypy):(?P\d.*)$", text) if m: @@ -237,47 +242,49 @@ def from_text(cls, text): version = version[:-1] version = Version.from_tox_like(version) - if version and version.is_valid: + if version is not None and version.is_valid: return cls(m.group("family"), version, is_min_spec=min_spec, freethreading=freethreading) @classmethod - def from_object(cls, value): + def from_object(cls, value) -> PythonSpec | None: """ Args: value: Value to transform into a PythonSpec, if possible Returns: - (PythonSpec | None): Parsed spec from given object, if valid + Parsed spec from given object; a returned PythonSpec is guaranteed to be valid (`is_valid` is True), + otherwise None is returned. """ - if not value or isinstance(value, PythonSpec): - return value or None + if not value: + return None + + if isinstance(value, PythonSpec): + return value if value.is_valid else None if isinstance(value, Version): - return cls(CPYTHON, value) + return cls(CPYTHON, value) if value.is_valid else None - if value: - return cls.from_text(str(value)) + return cls.from_text(str(value)) @classmethod - def to_list(cls, values): + def to_list(cls, values) -> list[PythonSpec]: """ Args: values: Values to transform into a list of PythonSpec-s Returns: - (list[PythonSpec]): Corresponding list of PythonSpec-s + Corresponding list of PythonSpec-s (only valid specs are retained) """ - values = flattened(values, split=",", transform=PythonSpec.from_object) - return [x for x in values if x and x.version] + return flattened(values, split=",", transform=PythonSpec.from_object) @classmethod - def guess_family(cls, text): + def guess_family(cls, text: str | None) -> str: """ Args: - text (str | None): Text to examine + text: Text to examine Returns: - (str): Guessed python family from given 'text' (typically path to installation) + Guessed python family from given 'text' (typically path to installation) """ if text: if "forge" in text or "conda" in text: @@ -292,6 +299,11 @@ def guess_family(cls, text): def abi_suffix(self): return "t" if self.freethreading else "" + @property + def is_valid(self) -> bool: + """Is this spec's `.version` a valid one?""" + return self.version.is_valid + class PythonDepot: """ @@ -302,7 +314,7 @@ class PythonDepot: p = my_depot.find_python("3.10") """ - _preferred_python: Optional["PythonInstallation"] = None # Preferred python to use, if configured + _preferred_python: PythonInstallation | None = None # Preferred python to use, if configured def __init__(self, *locations): """ @@ -497,27 +509,28 @@ def extracted_from_text(cls, text): return v @classmethod - def from_object(cls, obj) -> Optional["Version"]: + def from_object(cls, obj: Version | PythonSpec | tuple | list | float | str | None) -> Version | None: """ Args: obj: Object to turn into a Version, if possible Returns: - Corresponding version object, if valid + Corresponding Version; a returned Version is guaranteed to be valid (`is_valid` is True), + otherwise None is returned. """ if obj: if isinstance(obj, Version): return obj if obj.is_valid else None - if not isinstance(obj, str): - obj = joined(obj, delimiter=".") + if isinstance(obj, PythonSpec): + return obj.version if obj.version.is_valid else None - v = cls(obj) + v = cls(obj if isinstance(obj, str) else joined(obj, delimiter=".")) if v.is_valid: return v @classmethod - def from_tox_like(cls, text, default=None): + def from_tox_like(cls, text: str, default=None) -> Version | None: """Parse a version taking into account tox-like versions like '310' meaning '3.10'""" if text and re.match(r"^(\d\d+)$", text): return cls.from_object((text[0], text[1:])) @@ -597,7 +610,7 @@ def given_components_count(self): return len(self.given_components) if self.given_components else 0 @cached_property - def local_parts(self): + def local_parts(self) -> list[str] | None: """Local parts are only needed when comparing versions that differ solely by local part...""" if self.local_part: v = getattr(self, "_local_parts", None) @@ -830,8 +843,10 @@ def satisfies(self, given_spec): (bool): True if this python installation satisfies it """ if given_spec and self.family == given_spec.family and not self.problem: + # `full_spec` and `mm_spec` are either both set or both None (both derive from `full_version`) spec = self.full_spec if given_spec.version.given_components_count > 2 else self.mm_spec - return spec.satisfies(given_spec) + if spec is not None: + return spec.satisfies(given_spec) class PythonInstallationLocation: diff --git a/src/runez/render.py b/src/runez/render.py index 8c31687..772e8e2 100644 --- a/src/runez/render.py +++ b/src/runez/render.py @@ -5,7 +5,7 @@ from runez.colors import ColorManager from runez.convert import to_int -from runez.system import _R, flattened, joined, short, Slotted, stringified, SYS_INFO, UNSET, wcswidth +from runez.system import _R, flattened, joined, OptionalColor, short, Slotted, stringified, SYS_INFO, UNSET, wcswidth NAMED_BORDERS = { "ascii": "rstgrid,t:+++=,m:+++-", @@ -405,7 +405,14 @@ def _single_diag(sources, border, align, style, missing, columns) -> str: @staticmethod def two_column_diagnostics( - *sources, align="right", border=UNSET, columns=UNSET, delimiter="\n\n", missing="orange", style="bold", title_color="blue" + *sources, + align="right", + border=UNSET, + columns=UNSET, + delimiter="\n\n", + missing: OptionalColor = "orange", + style: OptionalColor = "bold", + title_color: OptionalColor = "blue", ): """ Args: @@ -414,9 +421,9 @@ def two_column_diagnostics( border (str): Border for the PrettyTable used to render the two-column diagnostics columns (int | None): Optional max number of columns in output (default: terminal width) delimiter (str): Delimiter to use between sub-diagnostics sections - missing (str | callable | None): Color to use to represent missing values - style (callable | None): Style for 2nd column - title_color (str | callable | None): Color to use for titles of sub-diagnostics sections + missing: Color to use to represent missing values + style: Style for 2nd column + title_color: Color to use for titles of sub-diagnostics sections Returns: (PrettyTable): Rendered PrettyTable showing diagnostics info @@ -623,7 +630,7 @@ def _iterate(source): yield source -def _represented_cell(text, missing_color): +def _represented_cell(text, missing_color: OptionalColor): if text is None: return _R.colored("-missing-", missing_color) diff --git a/src/runez/serialize.py b/src/runez/serialize.py index 72fc686..00189d5 100644 --- a/src/runez/serialize.py +++ b/src/runez/serialize.py @@ -690,7 +690,7 @@ def read_json(path, default=None, fatal=False, logger=False): return _R.habort(default, fatal, logger, "Can't read %s" % short(path), exc_info=e) -def represented_json(data, stringify=stringified, dt=str, none=False, indent=2, sort_keys=True): +def represented_json(data, stringify=stringified, dt=str, none=False, indent: int | None = 2, sort_keys=True) -> str: """ Args: data (object | None): Data to serialize diff --git a/src/runez/system.py b/src/runez/system.py index e4ac1f1..ee54ab1 100644 --- a/src/runez/system.py +++ b/src/runez/system.py @@ -18,13 +18,16 @@ import threading import unicodedata from io import StringIO -from typing import Any, Callable, ClassVar, TYPE_CHECKING, TypeVar +from typing import Any, Callable, ClassVar, Iterator, Literal, NoReturn, overload, TYPE_CHECKING, TypeVar if TYPE_CHECKING: from pathlib import Path _T = TypeVar("_T") +# Accepted forms for a "color" argument: a color/style name, a color callable (e.g. runez.red), or None (no coloring) +OptionalColor = Callable | str | None + ABORT_LOGGER = logging.error @@ -50,7 +53,17 @@ def __len__(self): UNSET: Any = Undefined() -def abort(message, code=1, exc_info=None, return_value: _T = None, fatal=True, logger=UNSET, stacklevel=1) -> _T: +@overload +def abort(message, code=1, exc_info=None, return_value=None, *, logger=UNSET, stacklevel=1) -> NoReturn: ... +@overload +def abort(message, code=1, exc_info=None, return_value=None, *, fatal: Literal[True] = True, logger=UNSET, stacklevel=1) -> NoReturn: ... +@overload +def abort(message, code=1, exc_info=None, return_value: _T = None, *, fatal: Literal[False] | None, logger=UNSET, stacklevel=1) -> _T: ... +@overload +def abort(message, code=1, exc_info=None, return_value=None, *, fatal: type[BaseException], logger=UNSET, stacklevel=1) -> NoReturn: ... +def abort( + message, code=1, exc_info=None, return_value: _T = None, fatal: bool | type[BaseException] | None = True, logger=UNSET, stacklevel=1 +) -> _T: """General wrapper for optionally fatal calls >>> from runez import abort @@ -150,23 +163,22 @@ def __init__(self, func): self.__name__ = func.__name__ @staticmethod - def _walk_properties(target, cached_only=True): - if target is not None: - parent_class = target if isinstance(target, type) else target.__class__ - for k, v in vars(parent_class).items(): - is_cached = isinstance(v, cached_property) - if is_cached or (not cached_only and isinstance(v, property)): - yield is_cached, k + def _walk_properties(target: object, cached_only=True) -> Iterator[tuple[bool, str]]: + parent_class = target if isinstance(target, type) else target.__class__ + for k, v in vars(parent_class).items(): + is_cached = isinstance(v, cached_property) + if is_cached or (not cached_only and isinstance(v, property)): + yield is_cached, k @staticmethod - def properties(target, cached_only=True): + def properties(target: object, cached_only=True) -> Iterator[str]: """ Args: target: Target object or class to examine cached_only (bool): If True, yield only names of `cached_property` objects (otherwise: also include regular `property`) - Returns: - Yield all (cached) properties of given `target`, if any + Yields: + All (cached) properties of given `target`, if any """ for _, property_name in cached_property._walk_properties(target, cached_only=cached_only): yield property_name @@ -180,10 +192,10 @@ def reset(target): delattr(target, property_name) @staticmethod - def to_dict(target, cached_only=True, existing_only=True, none=False, transform=None): + def to_dict(target: object, cached_only=True, existing_only=True, none=False, transform: Callable | None = None) -> dict: """ Args: - target: Target object to examine + target: Target instance to examine cached_only (bool): If True, restrict to `cached_property` fields only (otherwise: also include regular `property`) existing_only (bool): True: yield only computed cached properties (yield all otherwise) none (bool): False: filter out `None` keys/values, True: no filtering, keep `None` keys/values as-is @@ -192,18 +204,17 @@ def to_dict(target, cached_only=True, existing_only=True, none=False, transform= Returns: (dict): Key/value pairs of properties in `target` """ - if target is not None and not isinstance(target, type): - result = {} - for is_cached, property_name in cached_property._walk_properties(target, cached_only=cached_only): - if not existing_only or not is_cached or property_name in target.__dict__: - value = getattr(target, property_name) - if transform is not None: - value = transform(value) + result = {} + for is_cached, property_name in cached_property._walk_properties(target, cached_only=cached_only): + if not existing_only or not is_cached or property_name in target.__dict__: + value = getattr(target, property_name) + if transform is not None: + value = transform(value) - if none or value is not None: - result[property_name] = value + if none or value is not None: + result[property_name] = value - return result + return result def capped(value, minimum=None, maximum=None, key=None, none_ok=False): @@ -454,7 +465,7 @@ def stringified(value, converter=None, none: object | None = "None") -> str: return "{}".format(value) -def joined(*args, delimiter=" ", keep_empty: str | bool | None = False, strip=None, stringify=stringified, unique=False): +def joined(*args, delimiter=" ", keep_empty: str | bool | None = False, strip=None, stringify=stringified, unique=False) -> str: """ >>> joined(1, " foo ", None, 2) '1 foo 2' @@ -566,7 +577,7 @@ def resolved_path(path: str | Path, base=None) -> str: return os.path.abspath(path) -def short(value, size=UNSET, none="None", uncolor=False): +def short(value, size=UNSET, none="None", uncolor=False) -> str: """ Args: value: Value to textually represent in a shortened form @@ -2002,7 +2013,7 @@ def actual_message(message): return message() if callable(message) else message @classmethod - def colored(cls, text, color, is_coloring=UNSET): + def colored(cls, text, color: OptionalColor, is_coloring=UNSET): """Colored 'text' with 'color', 'is_coloring' can be used to override current coloring setting""" return cls.lc.rm.color.colored(text, color, is_coloring=is_coloring) diff --git a/tests/test_cached_property.py b/tests/test_cached_property.py index fe66ba4..e77fd5a 100644 --- a/tests/test_cached_property.py +++ b/tests/test_cached_property.py @@ -33,7 +33,6 @@ def test_simple_case(): assert MyObject.foo.__doc__ == "Some example property" assert MyObject.foo.__module__ == "tests.test_cached_property" assert MyObject.foo.__name__ == "foo" - assert cached_property.to_dict(MyObject) is None check_props(MyObject) obj1 = MyObject() diff --git a/tests/test_pyenv.py b/tests/test_pyenv.py index c6d277f..9ca11ab 100644 --- a/tests/test_pyenv.py +++ b/tests/test_pyenv.py @@ -12,7 +12,8 @@ def test_artifact_info(): - assert ArtifactInfo.from_basename("foo") is None + with pytest.raises(ValueError, match="Can't parse artifact info from basename: 'foo'"): + ArtifactInfo.from_basename("foo") info1 = ArtifactInfo.from_basename("E.S.P.-Hadouken-0.2.1.tar.gz") assert str(info1) == "e-s-p-hadouken/E.S.P.-Hadouken-0.2.1.tar.gz" @@ -193,7 +194,7 @@ def test_edge_cases(): invalid = PythonSpec("cpython", "invalid") assert str(invalid) == "cpython:invalid" assert invalid.abi_suffix == "" - assert invalid.version is None + assert not invalid.version.is_valid def test_empty_depot(): @@ -564,6 +565,35 @@ def test_version_extraction(): assert p38.is_valid +def test_version_from_object(): + # Falsy input -> None + assert Version.from_object(None) is None + assert Version.from_object("") is None + assert Version.from_object([]) is None + assert Version.from_object(0) is None + + # Valid Version instance is returned as-is + v = Version("1.2.3") + assert Version.from_object(v) is v + assert Version.from_object(PythonSpec("cpython", "1.2.3")) == Version("1.2.3") + + # Invalid Version instance -> None + assert Version.from_object(Version("foo")) is None + + # Valid string + assert Version.from_object("1.2.3") == Version("1.2.3") + + # Invalid string -> None + assert Version.from_object("foo") is None + + # Non-string iterable -> joined on "." + assert Version.from_object((1, 2, 3)) == Version("1.2.3") + assert Version.from_object([1, 2, 3]) == Version("1.2.3") + + # Non-string iterable that joins to an invalid version -> None + assert Version.from_object(["foo", "bar"]) is None + + def test_version_comparison(): v10rc5 = Version("1.0rc5") assert v10rc5 > "0.9" diff --git a/tests/test_testing.py b/tests/test_testing.py index 67c9b50..1f12de8 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -16,7 +16,7 @@ def sample_main(): args = runez.flattened(args, shellify=True) if args[0] == "TypeError": # Raise a TypeError - len(42) + raise TypeError("... oops ...") exit_code = runez.to_int(args[0]) if exit_code is not None: @@ -76,7 +76,7 @@ def test_crash(cli): cli.run("TypeError") assert cli.failed - assert cli.match("TypeError: ... has no len") + assert cli.match("TypeError: ... oops ...") cli.run("exit", "some message") assert cli.failed