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
12 changes: 12 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(python:*)",
"Bash(pytest:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git status:*)",
"Bash(git show:*)"
]
}
}
55 changes: 55 additions & 0 deletions docs/history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions src/runez/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
12 changes: 7 additions & 5 deletions src/runez/colors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
38 changes: 19 additions & 19 deletions src/runez/colors/named.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
65 changes: 46 additions & 19 deletions src/runez/logsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +25,7 @@
DEV,
find_caller,
flattened,
OptionalColor,
quoted,
short,
Slotted,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -706,20 +730,20 @@ 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
timeit = Timeit

_lock = threading.RLock()
_logging_snapshot = LoggingSnapshot()
_progress_handler: Optional[ProgressHandler] = None
_progress_handler: ProgressHandler | None = None

@classmethod
def set_debug(cls, debug):
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading