From e36c9dd439dda6be879f91ef17c89a7019702524 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Fri, 11 Sep 2026 11:56:49 +0200 Subject: [PATCH 1/4] Mark concrete method overrides explicitly Pyrefly's strict preset requires concrete overrides to be declared with @override. Mark the methods that intentionally replace Click or Cloup base implementations, including the concrete implementations of the overloaded command and group decorators. Besides satisfying strict checking, these markers make inheritance assumptions executable: a checker will report a renamed or removed base method instead of silently treating the subclass method as unrelated. Use typing.override where available and typing_extensions.override on Python 3.10 and 3.11. Extend the existing conditional typing_extensions dependency through Python 3.11 so importing Cloup does not rely on an undeclared runtime package. --- pyproject.toml | 2 +- src/cloup/_commands.py | 14 ++++++++++++++ src/cloup/_context.py | 7 +++++++ src/cloup/constraints/_conditional.py | 8 ++++++++ src/cloup/constraints/_core.py | 21 +++++++++++++++++++++ src/cloup/constraints/conditions.py | 22 ++++++++++++++++++++++ src/cloup/formatting/_formatter.py | 11 +++++++++++ 7 files changed, 84 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0325bc6..c7b65d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ ] dependencies = [ "click >= 8.5.0, < 9.0", - 'typing_extensions; python_version <= "3.10"', + 'typing_extensions; python_version < "3.12"', ] dynamic = [ "readme", diff --git a/src/cloup/_commands.py b/src/cloup/_commands.py index 17bc5c4..bc4bfee 100644 --- a/src/cloup/_commands.py +++ b/src/cloup/_commands.py @@ -21,6 +21,7 @@ """ import inspect +import sys from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from typing import ( Any, @@ -32,6 +33,11 @@ import click +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + import cloup from ._context import Context @@ -90,6 +96,7 @@ def get_normalized_epilog(self) -> str: return self.epilog or "" # Differently from Click, this doesn't indent the epilog. + @override def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: if self.epilog: assert isinstance(formatter, cloup.HelpFormatter) @@ -97,6 +104,7 @@ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> N formatter.write_paragraph() formatter.write_epilog(epilog) + @override def format_help_text( self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: @@ -109,6 +117,7 @@ def format_aliases(self, ctx: click.Context, formatter: click.HelpFormatter) -> assert isinstance(formatter, cloup.HelpFormatter) formatter.write_aliases(self.aliases) + @override def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: self.format_usage(ctx, formatter) self.format_aliases(ctx, formatter) @@ -180,6 +189,7 @@ def add_multiple_commands( for cmd in commands: self.add_command(cmd) + @override def add_command( self, cmd: click.Command, @@ -202,6 +212,7 @@ def resolve_command_name(self, ctx: click.Context, name: str) -> str | None: return name return self.alias2name.get(name) + @override def resolve_command( self, ctx: click.Context, args: list[str] ) -> tuple[str | None, click.Command | None, list[str]]: @@ -248,6 +259,7 @@ def must_show_subcommand_aliases(self, ctx: click.Context) -> bool: Group.SHOW_SUBCOMMAND_ALIASES, ) + @override def format_subcommand_name( self, ctx: click.Context, name: str, cmd: click.Command ) -> str: @@ -320,6 +332,7 @@ def command( # Why overloading? Refer to module docstring. **kwargs: Any, ) -> Callable[[AnyCallable], C]: ... + @override def command( self, name: str | None = None, @@ -409,6 +422,7 @@ def group( # Why overloading? Refer to module docstring. **kwargs: Any, ) -> Callable[[AnyCallable], G]: ... + @override def group( self, name: str | None = None, diff --git a/src/cloup/_context.py b/src/cloup/_context.py index 9c1a8e5..0e64ac1 100644 --- a/src/cloup/_context.py +++ b/src/cloup/_context.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys import warnings from collections.abc import Callable from functools import update_wrapper @@ -15,6 +16,11 @@ import click +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + import cloup from cloup._util import coalesce, pick_non_missing from cloup.formatting import HelpFormatter @@ -173,6 +179,7 @@ def get_formatter_settings(self) -> dict[str, Any]: **getattr(self.command, "formatter_settings", {}), } + @override def make_formatter(self) -> HelpFormatter: opts = self.get_formatter_settings() return self.formatter_class(**opts) diff --git a/src/cloup/constraints/_conditional.py b/src/cloup/constraints/_conditional.py index 2b96dae..2a498a4 100644 --- a/src/cloup/constraints/_conditional.py +++ b/src/cloup/constraints/_conditional.py @@ -2,10 +2,16 @@ This modules contains classes for creating conditional constraints. """ +import sys from collections.abc import Sequence from click import Context, Parameter +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + from .._util import make_repr from ._core import Constraint from .conditions import AllSet, IsSet, Predicate @@ -60,6 +66,7 @@ def help(self, ctx: Context) -> str: else: return f"{then_help} if {condition}, otherwise {else_help}" + @override def check_consistency(self, params: Sequence[Parameter]) -> None: self._then.check_consistency(params) if self._else: @@ -83,6 +90,7 @@ def check_values(self, params: Sequence[Parameter], ctx: Context) -> None: f"when {desc}, {err}", ctx=ctx, constraint=self, params=params ) + @override def __repr__(self) -> str: if self._else: return make_repr(self, self._condition, then=self._then, else_=self._else) diff --git a/src/cloup/constraints/_core.py b/src/cloup/constraints/_core.py index 17971fc..79a1695 100644 --- a/src/cloup/constraints/_core.py +++ b/src/cloup/constraints/_core.py @@ -1,4 +1,5 @@ import abc +import sys from collections.abc import Callable, Sequence from typing import ( Any, @@ -9,6 +10,11 @@ import click +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + from cloup._util import ( FrozenSpace, check_arg, @@ -260,6 +266,7 @@ def help(self, ctx: click.Context) -> str: for c in self.constraints ) + @override def check_consistency(self, params: Sequence[click.Parameter]) -> None: for c in self.constraints: c.check_consistency(params) @@ -277,6 +284,7 @@ def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> for c in self.constraints: c.check_values(params, ctx) + @override def __and__(self, other: Constraint) -> "And": if isinstance(other, And): return And(*self.constraints, *other.constraints) @@ -299,6 +307,7 @@ def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> self.help(ctx), ctx=ctx, constraint=self, params=params ) + @override def __or__(self, other: Constraint) -> "Or": if isinstance(other, Or): return Or(*self.constraints, *other.constraints) @@ -370,6 +379,7 @@ def _get_rephrased_error(self, err: ConstraintViolated) -> str | None: else: return self._error(err) + @override def check_consistency(self, params: Sequence[click.Parameter]) -> None: try: self.constraint.check_consistency(params) @@ -387,6 +397,7 @@ def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> ) raise + @override def __repr__(self) -> str: return make_one_line_repr(self, help=self._help) @@ -412,6 +423,7 @@ def __init__(self, constraint: Constraint, **attrs: Any): def help(self, ctx: click.Context) -> str: return self._constraint.help(ctx) + @override def check_consistency(self, params: Sequence[click.Parameter]) -> None: try: self._constraint.check_consistency(params) @@ -421,6 +433,7 @@ def check_consistency(self, params: Sequence[click.Parameter]) -> None: def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: self._constraint.check_values(params, ctx) + @override def __repr__(self) -> str: return make_repr(self, **self._attrs) @@ -462,6 +475,7 @@ def __init__(self, n: int): def help(self, ctx: click.Context) -> str: return f"at least {self.min_num_params} required" + @override def check_consistency(self, params: Sequence[click.Parameter]) -> None: n = self.min_num_params if len(params) < n: @@ -483,6 +497,7 @@ def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> params=params, ) + @override def __repr__(self) -> str: return make_repr(self, self.min_num_params) @@ -497,6 +512,7 @@ def __init__(self, n: int): def help(self, ctx: click.Context) -> str: return f"at most {self.max_num_params} accepted" + @override def check_consistency(self, params: Sequence[click.Parameter]) -> None: num_required_params = len(get_required_params(params)) if num_required_params > self.max_num_params: @@ -515,6 +531,7 @@ def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> params=params, ) + @override def __repr__(self) -> str: return make_repr(self, self.max_num_params) @@ -528,9 +545,11 @@ def __init__(self, n: int): super().__init__(RequireAtLeast(n) & AcceptAtMost(n)) self.num_params = n + @override def help(self, ctx: click.Context) -> str: return f"exactly {self.num_params} required" + @override def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: n = self.num_params given_params = get_params_whose_value_is_set(params, ctx.params) @@ -542,6 +561,7 @@ def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> ) + format_param_list(params) raise ConstraintViolated(reason, ctx=ctx, constraint=self, params=params) + @override def __repr__(self) -> str: return make_repr(self, self.num_params) @@ -561,6 +581,7 @@ def __init__(self, min: int, max: int): # noqa self.min_num_params = min self.max_num_params = max + @override def help(self, ctx: click.Context) -> str: return ( f"at least {self.min_num_params} required, " diff --git a/src/cloup/constraints/conditions.py b/src/cloup/constraints/conditions.py index 1e5ade9..47e88c0 100644 --- a/src/cloup/constraints/conditions.py +++ b/src/cloup/constraints/conditions.py @@ -7,10 +7,16 @@ """ import abc +import sys from typing import Any, Generic, TypeVar import click +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + from .._util import make_repr from ._support import ensure_constraints_support from .common import ( @@ -85,15 +91,18 @@ def __init__(self, predicate: P): def description(self, ctx: click.Context) -> str: return self.predicate.negated_description(ctx) + @override def negated_description(self, ctx: click.Context) -> str: return self.predicate.description(ctx) def __call__(self, ctx: click.Context) -> bool: return not self.predicate(ctx) + @override def __invert__(self) -> P: return self.predicate + @override def __repr__(self) -> str: return f"Not({self.predicate!r})" @@ -114,6 +123,7 @@ def description(self, ctx: click.Context) -> str: for p in self.predicates ) + @override def __repr__(self) -> str: return make_repr(self, *self.predicates) @@ -123,6 +133,7 @@ class _And(_Operator): DESC_SEP = " and " + @override def negated_description(self, ctx: click.Context) -> str: return " or ".join( f"({p.neg_desc(ctx)})" if isinstance(p, _Operator) else p.neg_desc(ctx) @@ -132,6 +143,7 @@ def negated_description(self, ctx: click.Context) -> str: def __call__(self, ctx: click.Context) -> bool: return all(p(ctx) for p in self.predicates) + @override def __and__(self, other: "Predicate") -> Predicate: if isinstance(other, _And): return _And(*self.predicates, *other.predicates) @@ -143,6 +155,7 @@ class _Or(_Operator): DESC_SEP = " or " + @override def negated_description(self, ctx: click.Context) -> str: return " and ".join( f"({p.neg_desc(ctx)})" if isinstance(p, _Operator) else p.neg_desc(ctx) @@ -152,6 +165,7 @@ def negated_description(self, ctx: click.Context) -> str: def __call__(self, ctx: click.Context) -> bool: return any(p(ctx) for p in self.predicates) + @override def __or__(self, other: "Predicate") -> Predicate: if isinstance(other, _Or): return _Or(*self.predicates, *other.predicates) @@ -167,6 +181,7 @@ def __init__(self, param_name: str): def description(self, ctx: click.Context) -> str: return f"{param_label_by_name(ctx, self.param_name)} is set" + @override def negated_description(self, ctx: click.Context) -> str: return f"{param_label_by_name(ctx, self.param_name)} is not set" @@ -176,11 +191,13 @@ def __call__(self, ctx: click.Context) -> bool: value = param_value_by_name(ctx, self.param_name) return param_value_is_set(param, value) + @override def __and__(self, other: Predicate) -> Predicate: if isinstance(other, IsSet): return AllSet(self.param_name, other.param_name) return super().__and__(other) + @override def __or__(self, other: Predicate) -> Predicate: if isinstance(other, IsSet): return AnySet(self.param_name, other.param_name) @@ -198,6 +215,7 @@ def __init__(self, *param_names: str): raise ValueError("you must provide at least one param name") self.param_names = param_names + @override def negated_description(self, ctx: click.Context) -> str: labels = get_param_labels(ctx, self.param_names) if len(labels) == 1: @@ -220,6 +238,7 @@ def __call__(self, ctx: click.Context) -> bool: for param in params ) + @override def __and__(self, other: Predicate) -> Predicate: if isinstance(other, AllSet): return AllSet(*self.param_names, *other.param_names) @@ -237,6 +256,7 @@ def __init__(self, *param_names: str): raise ValueError("you must provide at least one param name") self.param_names = param_names + @override def negated_description(self, ctx: click.Context) -> str: labels = get_param_labels(ctx, self.param_names) if len(labels) == 1: @@ -261,6 +281,7 @@ def __call__(self, ctx: click.Context) -> bool: for param in params ) + @override def __or__(self, other: Predicate) -> Predicate: if isinstance(other, AnySet): return AnySet(*self.param_names, *other.param_names) @@ -278,6 +299,7 @@ def description(self, ctx: click.Context) -> str: param_label = param_label_by_name(ctx, self.param_name) return f'{param_label}="{self.value}"' + @override def negated_description(self, ctx: click.Context) -> str: param_label = param_label_by_name(ctx, self.param_name) return f'{param_label}!="{self.value}"' diff --git a/src/cloup/formatting/_formatter.py b/src/cloup/formatting/_formatter.py index 0da9c81..17f2225 100644 --- a/src/cloup/formatting/_formatter.py +++ b/src/cloup/formatting/_formatter.py @@ -1,6 +1,7 @@ import dataclasses as dc import inspect import shutil +import sys import textwrap from collections.abc import Callable, Iterable, Iterator, Sequence from itertools import chain @@ -18,6 +19,11 @@ import click from click.formatting import wrap_text +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + from cloup._util import ( check_positive_int, identity, @@ -180,9 +186,11 @@ def settings( def available_width(self) -> int: return self.width - self.current_indent + @override def write(self, string: str = "", *strings: str) -> None: self.buffer += (string, *strings) + @override def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None: prefix = "Usage:" if prefix is None else prefix prefix = self.theme.heading(prefix) + " " @@ -205,6 +213,7 @@ def write_command_help_text(self, cmd: click.Command) -> None: with self.indentation(): self.write_text(help_text, style=self.theme.command_help) + @override def write_heading(self, heading: str, newline: bool = True) -> None: if self.current_indent: self.write(" " * self.current_indent) @@ -249,6 +258,7 @@ def write_section(self, s: HelpSection, col1_width: int | None = None) -> None: self.write_text(s.help, theme.section_help) self.write_dl(s.definitions, col1_width=col1_width) + @override def write_text(self, text: str, style: IStyle = identity) -> None: wrapped = wrap_text( text, self.width - self.current_indent, preserve_paragraphs=True @@ -266,6 +276,7 @@ def compute_col1_width(self, rows: Iterable[Definition], max_width: int) -> int: lengths_under_limit = (length for length in col1_lengths if length <= max_width) return max(lengths_under_limit, default=0) + @override def write_dl( self, rows: Iterable[Definition], From 2de8f44df208a3088d7b9d7741451dd2cfcfbd99 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Fri, 11 Sep 2026 11:58:42 +0200 Subject: [PATCH 2/4] Type parameter decorator implementations The public signatures for argument(), option(), and Option live in _params.pyi, but Pyrefly also checks the implementation file in strict mode. Its previously unannotated variadic parameters and nested decorators therefore became implicit Any errors. Add broad implementation annotations that agree with the stub while leaving the detailed keyword contract in one place. Use the existing callback type variable so each decorator remains explicitly identity-preserving. Once the callback is typed, reading __click_params__ from it is invalid because Click creates that private attribute dynamically inside _param_memo(). Keep the newly constructed Option instead of retrieving the same object from that list, and use setattr for Cloup's intentional dynamic group extension. This preserves the existing attach-then-configure order without hiding the value behind Any. --- src/cloup/_params.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/cloup/_params.py b/src/cloup/_params.py index b84fa11..0957bca 100644 --- a/src/cloup/_params.py +++ b/src/cloup/_params.py @@ -1,10 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import click from click.decorators import _param_memo +from .typing import F + if TYPE_CHECKING: from ._option_groups import OptionGroup @@ -14,7 +17,12 @@ class Option(click.Option): group: OptionGroup | None - def __init__(self, *args, group=None, **attrs): + def __init__( + self, + *args: Any, + group: OptionGroup | None = None, + **attrs: Any, + ) -> None: super().__init__(*args, **attrs) self.group = group @@ -23,17 +31,26 @@ def __init__(self, *args, group=None, **attrs): """Alias of ``Option``.""" -def argument(*param_decls, cls=None, **attrs): +def argument( + *param_decls: str, + cls: type[click.Argument] | None = None, + **attrs: Any, +) -> Callable[[F], F]: cls = cls or click.Argument - def decorator(f): + def decorator(f: F) -> F: _param_memo(f, cls(param_decls, **attrs)) return f return decorator -def option(*param_decls, cls=None, group=None, **attrs): +def option( + *param_decls: str, + cls: type[click.Option] | None = None, + group: OptionGroup | None = None, + **attrs: Any, +) -> Callable[[F], F]: """Attach an ``Option`` to the command. Refer to :class:`click.Option` and :class:`click.Parameter` for more info about the accepted parameters. @@ -43,10 +60,10 @@ def option(*param_decls, cls=None, group=None, **attrs): """ OptionClass = cls or Option - def decorator(f): - _param_memo(f, OptionClass(param_decls, **attrs)) - new_option = f.__click_params__[-1] - new_option.group = group + def decorator(f: F) -> F: + new_option = OptionClass(param_decls, **attrs) + _param_memo(f, new_option) + setattr(new_option, "group", group) if group and group.hidden: new_option.hidden = True return f From 6b612ad2b20693d9b687b96205b4481e6739bfc3 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Fri, 11 Sep 2026 11:59:44 +0200 Subject: [PATCH 3/4] Type the constraint decorator accumulator _constraint_memo() attaches a list dynamically to decorated callbacks. An empty list provides no element evidence, so Pyrefly strict mode inferred Any for its contents and reported the container. Annotate the newly created accumulator with the same BoundConstraintSpec / BoundConstraint union accepted by the function. This documents the dynamic attribute's invariant without changing its runtime representation. --- src/cloup/constraints/_support.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cloup/constraints/_support.py b/src/cloup/constraints/_support.py index 43891b6..015eae4 100644 --- a/src/cloup/constraints/_support.py +++ b/src/cloup/constraints/_support.py @@ -32,7 +32,8 @@ def _constraint_memo( f: Any, constr: Union[BoundConstraintSpec, "BoundConstraint"] ) -> None: if not hasattr(f, "__cloup_constraints__"): - f.__cloup_constraints__ = [] + constraints: list[BoundConstraintSpec | BoundConstraint] = [] + f.__cloup_constraints__ = constraints f.__cloup_constraints__.append(constr) From f1cd7397837ee0b1b0dec47ab5f69776fb25de90 Mon Sep 17 00:00:00 2001 From: Gianluca Gippetto Date: Fri, 11 Sep 2026 12:02:25 +0200 Subject: [PATCH 4/4] Enable strict Pyrefly checks for package sources The default preset was useful for initial adoption, but the distributable package is fully annotated and can support Pyrefly's stricter diagnostics. Check src with the strict preset while tests and examples retain the default policy introduced with the initial Pyrefly integration. Pyrefly does not permit preset selection inside a sub-config. Run the two scopes separately in the Hatch typing script so src uses the real strict preset instead of copying its current settings into pyproject.toml. This also means future changes to Pyrefly's strict preset are exercised automatically. Run the strict source check first for fast feedback, followed by the default tests and examples check. Task and CI matrix entry points continue to invoke the same Hatch script. --- hatch.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hatch.toml b/hatch.toml index 6d72ad1..961501e 100644 --- a/hatch.toml +++ b/hatch.toml @@ -34,7 +34,8 @@ typing-mypy = [ "mypy tests examples", ] typing-pyrefly = [ - "pyrefly check", + "pyrefly check --preset strict src/cloup", + "pyrefly check tests examples", ] typing = [ "click-version",