Skip to content
4 changes: 3 additions & 1 deletion distutils/_modified.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from collections.abc import Callable, Iterable
from typing import Literal, TypeVar

from jaraco.functools import splat
from jaraco.functools import (
splat, # jaraco/jaraco.functools#30
)

from .compat.py39 import zip_strict
from .errors import DistutilsFileError
Expand Down
37 changes: 23 additions & 14 deletions distutils/archive_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@
from __future__ import annotations

import os
from collections.abc import Callable
from types import ModuleType
from typing import Literal, overload

try:
import zipfile
except ImportError:
zipfile = None


from ._log import log
from .dir_util import mkpath
from .errors import DistutilsExecError
from .spawn import spawn

zipfile: ModuleType | None = None
try:
import zipfile
except ImportError:
pass

# mypy: disable-error-code="attr-defined"
# We have to be more flexible than simply checking for `sys.platform != "win32"`
# https://github.com/python/mypy/issues/1393
try:
from pwd import getpwnam
except ImportError:
Expand Down Expand Up @@ -85,16 +90,15 @@ def make_tarball(
'xz': 'xz',
None: '',
}
compress_ext = {'gzip': '.gz', 'bzip2': '.bz2', 'xz': '.xz'}
compress_ext = {'gzip': '.gz', 'bzip2': '.bz2', 'xz': '.xz', None: ''}

# flags for compression program, each element of list will be an argument
if compress is not None and compress not in compress_ext.keys():
if compress not in compress_ext.keys():
raise ValueError(
"bad value for 'compress': must be None, 'gzip', 'bzip2', 'xz'"
f"bad value for 'compress': must be one of {list(compress_ext.keys())!r}"
)

archive_name = base_name + '.tar'
archive_name += compress_ext.get(compress, '')
archive_name = base_name + '.tar' + compress_ext[compress]

mkpath(os.path.dirname(archive_name), dry_run=dry_run)

Expand All @@ -116,7 +120,10 @@ def _set_uid_gid(tarinfo):
return tarinfo

if not dry_run:
tar = tarfile.open(archive_name, f'w|{tar_compression[compress]}')
tar = tarfile.open(
archive_name,
f'w|{tar_compression[compress]}', # type: ignore[call-overload] # Typeshed doesn't allow non-literal string here
)
try:
tar.add(base_dir, filter=_set_uid_gid)
finally:
Expand Down Expand Up @@ -191,7 +198,9 @@ def make_zipfile( # noqa: C901
return zip_filename


ARCHIVE_FORMATS = {
ARCHIVE_FORMATS: dict[
str, tuple[Callable[..., str], list[tuple[str, str | None]], str]
] = {
'gztar': (make_tarball, [('compress', 'gzip')], "gzip'ed tar-file"),
'bztar': (make_tarball, [('compress', 'bzip2')], "bzip2'ed tar-file"),
'xztar': (make_tarball, [('compress', 'xz')], "xz'ed tar-file"),
Expand Down Expand Up @@ -270,7 +279,7 @@ def make_archive(
if base_dir is None:
base_dir = os.curdir

kwargs = {'dry_run': dry_run}
kwargs: dict[str, str | bool | None] = {'dry_run': dry_run}

try:
format_info = ARCHIVE_FORMATS[format]
Expand Down
15 changes: 9 additions & 6 deletions distutils/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import sys
from abc import abstractmethod
from collections.abc import Callable, MutableSequence
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast, overload

from . import _modified, archive_util, dir_util, file_util, util
from ._log import log
Expand Down Expand Up @@ -70,6 +70,8 @@ class Command:
list[tuple[str, str, str]] | list[tuple[str, str | None, str]]
] = []

description = ""

# -- Creation/initialization methods -------------------------------

def __init__(self, dist: Distribution) -> None:
Expand Down Expand Up @@ -107,7 +109,7 @@ def __init__(self, dist: Distribution) -> None:
# timestamps, but methods defined *here* assume that
# 'self.force' exists for all commands. So define it here
# just to be safe.
self.force = None
self.force: bool | None = None

# The 'help' flag is just used for command-line parsing, so
# none of that complicated bureaucracy is needed.
Expand Down Expand Up @@ -330,7 +332,8 @@ def get_finalized_command(self, command: str, create: bool = True) -> Command:
'command', call its 'ensure_finalized()' method, and return the
finalized command object.
"""
cmd_obj = self.distribution.get_command_obj(command, create)
# TODO: Raise a more descriptive error when cmd_obj is None ?
cmd_obj = cast(Command, self.distribution.get_command_obj(command, create))
Comment on lines +335 to +336
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision to be taken here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My instinct - get_command_obj should raise the error and always return Command... unless there's a case where None is legitimately expected, in which case this should be wrapped in a helper to raise that error and set the expectation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just did a quick scan through the distutils and setuptools codebase and there's not a single call to get_finalized_command(..., create=False). Maybe the create flag is just cruft that can be eliminated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened an exploratory PR deprecating (unused, but not removed, for backward compatibility) the create arg: #377

cmd_obj.ensure_finalized()
return cmd_obj

Expand Down Expand Up @@ -504,7 +507,7 @@ def make_archive(
owner: str | None = None,
group: str | None = None,
) -> str:
return archive_util.make_archive(
return archive_util.make_archive( # type: ignore[misc] # Mypy bailed out
base_name,
format,
root_dir,
Expand Down Expand Up @@ -533,7 +536,7 @@ def make_file(
timestamp checks.
"""
if skip_msg is None:
skip_msg = f"skipping {outfile} (inputs unchanged)"
skip_msg = f"skipping {outfile!r} (inputs unchanged)"

# Allow 'infiles' to be a single string
if isinstance(infiles, str):
Expand All @@ -542,7 +545,7 @@ def make_file(
raise TypeError("'infiles' must be a string, or a list or tuple of strings")

if exec_msg is None:
exec_msg = "generating {} from {}".format(outfile, ', '.join(infiles))
exec_msg = f"generating {outfile!r} from {', '.join(infiles)}"

# If 'outfile' must be regenerated (either because it doesn't
# exist, is out-of-date, or the 'force' flag is true) then
Expand Down
2 changes: 1 addition & 1 deletion distutils/command/bdist_rpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ def run(self) -> None: # noqa: C901
spec_dir = self.dist_dir
self.mkpath(spec_dir)
else:
rpm_dir = {}
rpm_dir: dict[str, str] = {}
for d in ('SOURCES', 'SPECS', 'BUILD', 'RPMS', 'SRPMS'):
rpm_dir[d] = os.path.join(self.rpm_base, d)
self.mkpath(rpm_dir[d])
Expand Down
2 changes: 1 addition & 1 deletion distutils/command/build_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ def build_modules(self) -> None:
self.build_module(module, module_file, package)

def build_packages(self) -> None:
for package in self.packages:
for package in self.packages or ():
# Get list of (package, module, module_file) tuples based on
# scanning the package directory. 'package' is only included
# in the tuple so that 'find_modules()' and
Expand Down
4 changes: 2 additions & 2 deletions distutils/command/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,8 @@ def initialize_options(self) -> None:
# These select only the installation base; it's up to the user to
# specify the installation scheme (currently, that means supplying
# the --install-{platlib,purelib,scripts,data} options).
self.install_base = None
self.install_platbase = None
self.install_base: str | None = None
self.install_platbase: str | None = None
self.root: str | None = None

# These options are the actual installation directories; if not
Expand Down
2 changes: 1 addition & 1 deletion distutils/command/install_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def install(self) -> list[str] | Any:
self.warn(
f"'{self.build_dir}' does not exist -- no Python modules to install"
)
return
return None
return outfiles

def byte_compile(self, files) -> None:
Expand Down
4 changes: 2 additions & 2 deletions distutils/compilers/C/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ def _fix_compile_args(
output_dir: str | None,
macros: list[_Macro] | None,
include_dirs: list[str] | tuple[str, ...] | None,
) -> tuple[str, list[_Macro], list[str]]:
) -> tuple[str | None, list[_Macro], list[str]]:
"""Typecheck and fix-up some of the arguments to the 'compile()'
method, and return fixed-up values. Specifically: if 'output_dir'
is None, replaces it with 'self.output_dir'; ensures that 'macros'
Expand Down Expand Up @@ -466,7 +466,7 @@ def _prep_compile(self, sources, output_dir, depends=None):

def _fix_object_args(
self, objects: list[str] | tuple[str, ...], output_dir: str | None
) -> tuple[list[str], str]:
) -> tuple[list[str], str | None]:
"""Typecheck and fix up some arguments supplied to various methods.
Specifically: ensure that 'objects' is a list; if output_dir is
None, replace with self.output_dir. Return fixed versions of
Expand Down
9 changes: 5 additions & 4 deletions distutils/dir_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ def mkpath(name: pathlib.Path, mode=0o777, verbose=True, dry_run=False) -> None:
if verbose and not name.is_dir():
log.info("creating %s", name)

try:
dry_run or name.mkdir(mode=mode, parents=True, exist_ok=True)
except OSError as exc:
raise DistutilsFileError(f"could not create '{name}': {exc.args[-1]}")
if not dry_run:
try:
name.mkdir(mode=mode, parents=True, exist_ok=True)
except OSError as exc:
raise DistutilsFileError(f"could not create '{name}': {exc.args[-1]}")
Comment on lines +60 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't love how this code gets more nested. What is it about mypy that's forcing different logic here?

Copy link
Contributor Author

@Avasam Avasam Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one could be ignored on the line itself without being unsafe.

It's an unused expression, both mypy and pyright warn here because of the lack of assignement, it "looks like" a possible mistake because the result of the expression is not used for anything. But it's just a conditional execution pattern.

It's definitely more of a lint than a type error.

FWIW, #334 / #335 would remove the condition anyway.



@mkpath.register
Expand Down
14 changes: 6 additions & 8 deletions distutils/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Literal,
TypeVar,
Union,
cast,
overload,
)

Expand Down Expand Up @@ -576,11 +577,10 @@ def _parse_command_opts(self, parser, args): # noqa: C901
hasattr(cmd_class, 'user_options')
and isinstance(cmd_class.user_options, list)
):
msg = (
"command class %s must provide "
raise DistutilsClassError(
f"command class {cmd_class} must provide "
"'user_options' attribute (a list of tuples)"
)
raise DistutilsClassError(msg % cmd_class)

# If the command class has a list of negative alias options,
# merge it in with the global negative aliases.
Expand Down Expand Up @@ -849,7 +849,7 @@ def get_command_class(self, command: str) -> type[Command]:
continue

try:
klass = getattr(module, klass_name)
klass = cast(type[Command], getattr(module, klass_name))
except AttributeError:
raise DistutilsModuleError(
f"invalid command '{command}' (no class '{klass_name}' in module '{module_name}')"
Expand All @@ -865,9 +865,7 @@ def get_command_obj(
self, command: str, create: Literal[True] = True
) -> Command: ...
@overload
def get_command_obj(
self, command: str, create: Literal[False]
) -> Command | None: ...
def get_command_obj(self, command: str, create: bool) -> Command | None: ...
def get_command_obj(self, command: str, create: bool = True) -> Command | None:
"""Return the command object for 'command'. Normally this object
is cached on a previous call to 'get_command_obj()'; if no command
Expand Down Expand Up @@ -1198,7 +1196,7 @@ def _read_list(name):
self.description = _read_field('summary')

if 'keywords' in msg:
self.keywords = _read_field('keywords').split(',')
self.keywords = _read_field('keywords').split(',') # type:ignore[union-attr] # Manually checked

self.platforms = _read_list('platform')
self.classifiers = _read_list('classifier')
Expand Down
3 changes: 1 addition & 2 deletions distutils/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ def __init__(

# If there are unknown keyword options, warn about them
if len(kw) > 0:
options = [repr(option) for option in kw]
options = ', '.join(sorted(options))
options = ', '.join(sorted([repr(option) for option in kw]))
msg = f"Unknown Extension options: {options}"
warnings.warn(msg)

Expand Down
15 changes: 8 additions & 7 deletions distutils/fancy_getopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def getopt(self, args: Sequence[str] | None = None, object=None): # noqa: C901
raise DistutilsArgError(msg)

for opt, val in opts:
value: int | str = val
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is gross. val and value are the same thing. Mypy is forcing some idiosyncratic code. There's got to be a better way (cast opts.__iter__ to the expected types?).

Copy link
Contributor Author

@Avasam Avasam Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this may be resolved in mypy 1.18: https://mypy.readthedocs.io/en/stable/changelog.html#flexible-variable-definitions-update

Before 1.18 the flag wasn't stabilized and called allow-redefinition-new (I wasn't aware of its existence either)

Edit: No it doesn't help here because of the difference of scope

Copy link
Contributor Author

@Avasam Avasam Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's this related request python/mypy#15988
Which would allow val: str | int = val which is much safer than a cast.

As for casting, I could do

        # Unsafely casting to avoid using a different variable
        # python/mypy#15988 could allow `val: str | int = val`
        for opt, val in cast("list[tuple[str, int | str]]", opts):

if len(opt) == 2 and opt[0] == '-': # it's a short option
opt = self.short2long[opt[1]]
else:
Expand All @@ -260,21 +261,21 @@ def getopt(self, args: Sequence[str] | None = None, object=None): # noqa: C901
opt = alias

if not self.takes_arg[opt]: # boolean option?
assert val == '', "boolean option can't have value"
assert value == '', "boolean option can't have value"
alias = self.negative_alias.get(opt)
if alias:
opt = alias
val = 0
value = 0
else:
val = 1
value = 1

attr = self.attr_name[opt]
# The only repeating option at the moment is 'verbose'.
# It has a negative option -q quiet, which should set verbose = False.
if val and self.repeat.get(attr) is not None:
val = getattr(object, attr, 0) + 1
setattr(object, attr, val)
self.option_order.append((opt, val))
if value and self.repeat.get(attr) is not None:
value = getattr(object, attr, 0) + 1
setattr(object, attr, value)
self.option_order.append((opt, value))

# for opts
if created_object:
Expand Down
10 changes: 5 additions & 5 deletions distutils/filelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def process_template_line(self, line: str) -> None: # noqa: C901
@overload
def include_pattern(
self,
pattern: str,
pattern: str | None,
anchor: bool = True,
prefix: str | None = None,
is_regex: Literal[False] = False,
Expand All @@ -224,7 +224,7 @@ def include_pattern(
) -> bool: ...
def include_pattern(
self,
pattern: str | re.Pattern,
pattern: str | re.Pattern | None,
anchor: bool = True,
prefix: str | None = None,
is_regex: bool = False,
Expand Down Expand Up @@ -262,7 +262,7 @@ def include_pattern(
if self.allfiles is None:
self.findall()

for name in self.allfiles:
for name in self.allfiles: # type: ignore[union-attr] # Calling findall fills allfiles
if pattern_re.search(name):
self.debug_print(" adding " + name)
self.files.append(name)
Expand All @@ -272,7 +272,7 @@ def include_pattern(
@overload
def exclude_pattern(
self,
pattern: str,
pattern: str | None,
anchor: bool = True,
prefix: str | None = None,
is_regex: Literal[False] = False,
Expand All @@ -296,7 +296,7 @@ def exclude_pattern(
) -> bool: ...
def exclude_pattern(
self,
pattern: str | re.Pattern,
pattern: str | re.Pattern | None,
anchor: bool = True,
prefix: str | None = None,
is_regex: bool = False,
Expand Down
Loading