From da12e5883aab9da0c386dfdd1a1a4fa49c046141 Mon Sep 17 00:00:00 2001 From: Eli Schwartz Date: Fri, 30 Aug 2024 17:57:05 -0400 Subject: [PATCH] Migrate several classes over to dataclasses (#200) Python 3.7 has been the minimum requirement for a while now, which brings dataclasses into the standard library. This reduces the boilerplate necessary to write nice classes with init and repr methods that simply do the right thing. As a side effect, fix the repr of installer.scripts.Script, which was missing the trailing close-parenthesis: ``` >>> import installer >>> installer.scripts.Script('name', 'module', 'attr', 'section') Script(name='name', module='module', attr='attr' ``` --- src/installer/destinations.py | 68 +++++++++++++++++------------------ src/installer/records.py | 64 +++++++++++++-------------------- src/installer/scripts.py | 32 +++++++---------- 3 files changed, 72 insertions(+), 92 deletions(-) diff --git a/src/installer/destinations.py b/src/installer/destinations.py index 982e6adf..c1fe1923 100644 --- a/src/installer/destinations.py +++ b/src/installer/destinations.py @@ -2,6 +2,7 @@ import io import os +from dataclasses import dataclass from pathlib import Path from typing import ( TYPE_CHECKING, @@ -100,43 +101,42 @@ def finalize_installation( raise NotImplementedError +@dataclass class SchemeDictionaryDestination(WheelDestination): """Destination, based on a mapping of {scheme: file-system-path}.""" - def __init__( - self, - scheme_dict: Dict[str, str], - interpreter: str, - script_kind: "LauncherKind", - hash_algorithm: str = "sha256", - bytecode_optimization_levels: Collection[int] = (), - destdir: Optional[str] = None, - overwrite_existing: bool = False, - ) -> None: - """Construct a ``SchemeDictionaryDestination`` object. - - :param scheme_dict: a mapping of {scheme: file-system-path} - :param interpreter: the interpreter to use for generating scripts - :param script_kind: the "kind" of launcher script to use - :param hash_algorithm: the hashing algorithm to use, which is a member - of :any:`hashlib.algorithms_available` (ideally from - :any:`hashlib.algorithms_guaranteed`). - :param bytecode_optimization_levels: Compile cached bytecode for - installed .py files with these optimization levels. The bytecode - is specific to the minor version of Python (e.g. 3.10) used to - generate it. - :param destdir: A staging directory in which to write all files. This - is expected to be the filesystem root at runtime, so embedded paths - will be written as though this was the root. - :param overwrite_existing: silently overwrite existing files. - """ - self.scheme_dict = scheme_dict - self.interpreter = interpreter - self.script_kind = script_kind - self.hash_algorithm = hash_algorithm - self.bytecode_optimization_levels = bytecode_optimization_levels - self.destdir = destdir - self.overwrite_existing = overwrite_existing + scheme_dict: Dict[str, str] + """A mapping of {scheme: file-system-path}""" + + interpreter: str + """The interpreter to use for generating scripts.""" + + script_kind: "LauncherKind" + """The "kind" of launcher script to use.""" + + hash_algorithm: str = "sha256" + """ + The hashing algorithm to use, which is a member of + :any:`hashlib.algorithms_available` (ideally from + :any:`hashlib.algorithms_guaranteed`). + """ + + bytecode_optimization_levels: Collection[int] = () + """ + Compile cached bytecode for installed .py files with these optimization + levels. The bytecode is specific to the minor version of Python (e.g. 3.10) + used to generate it. + """ + + destdir: Optional[str] = None + """ + A staging directory in which to write all files. This is expected to be the + filesystem root at runtime, so embedded paths will be written as though + this was the root. + """ + + overwrite_existing: bool = False + """Silently overwrite existing files.""" def _path_with_destdir(self, scheme: Scheme, path: str) -> str: file = os.path.join(self.scheme_dict[scheme], path) diff --git a/src/installer/records.py b/src/installer/records.py index 8fa06745..4be51912 100644 --- a/src/installer/records.py +++ b/src/installer/records.py @@ -4,6 +4,7 @@ import csv import hashlib import os +from dataclasses import dataclass from typing import BinaryIO, Iterable, Iterator, Optional, Tuple, cast from installer.utils import copyfileobj_with_hashing, get_stream_length @@ -16,46 +17,34 @@ ] +@dataclass class InvalidRecordEntry(Exception): """Raised when a RecordEntry is not valid, due to improper element values or count.""" - def __init__( # noqa: D107 - self, elements: Iterable[str], issues: Iterable[str] - ) -> None: - super().__init__(", ".join(issues)) - self.issues = issues - self.elements = elements + elements: Iterable[str] + issues: Iterable[str] - def __repr__(self) -> str: - return f"InvalidRecordEntry(elements={self.elements!r}, issues={self.issues!r})" + def __post_init__(self) -> None: + super().__init__(", ".join(self.issues)) +@dataclass class Hash: - """Represents the "hash" element of a RecordEntry.""" + """Represents the "hash" element of a RecordEntry. - def __init__(self, name: str, value: str) -> None: - """Construct a ``Hash`` object. + Most consumers should use :py:meth:`Hash.parse` instead, since no + validation or parsing is performed by this constructor. + """ - Most consumers should use :py:meth:`Hash.parse` instead, since no - validation or parsing is performed by this constructor. + name: str + """Name of the hash function.""" - :param name: name of the hash function - :param value: hashed value - """ - self.name = name - self.value = value + value: str + """Hashed value.""" def __str__(self) -> str: return f"{self.name}={self.value}" - def __repr__(self) -> str: - return f"Hash(name={self.name!r}, value={self.value!r})" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, Hash): - return NotImplemented - return self.value == other.value and self.name == other.name - def validate(self, data: bytes) -> bool: """Validate that ``data`` matches this instance. @@ -83,27 +72,24 @@ def parse(cls, h: str) -> "Hash": return cls(name, value) +@dataclass class RecordEntry: """Represents a single record in a RECORD file. A list of :py:class:`RecordEntry` objects fully represents a RECORD file. - """ - def __init__(self, path: str, hash_: Optional[Hash], size: Optional[int]) -> None: - r"""Construct a ``RecordEntry`` object. + Most consumers should use :py:meth:`RecordEntry.from_elements`, since no + validation or parsing is performed by this constructor. + """ - Most consumers should use :py:meth:`RecordEntry.from_elements`, since no - validation or parsing is performed by this constructor. + path: str + """File's path.""" - :param path: file's path - :param hash\_: hash of the file's contents - :param size: file's size in bytes - """ - super().__init__() + hash_: Optional[Hash] + """Hash of the file's contents.""" - self.path = path - self.hash_ = hash_ - self.size = size + size: Optional[int] + """File's size in bytes.""" def to_row(self, path_prefix: Optional[str] = None) -> Tuple[str, str, str]: """Convert this into a 3-element tuple that can be written in a RECORD file. diff --git a/src/installer/scripts.py b/src/installer/scripts.py index 7c8fc820..0c7b7ea0 100644 --- a/src/installer/scripts.py +++ b/src/installer/scripts.py @@ -5,6 +5,7 @@ import shlex import sys import zipfile +from dataclasses import dataclass, field from types import ModuleType from typing import TYPE_CHECKING, Mapping, Optional, Tuple, Union @@ -90,31 +91,24 @@ class InvalidScript(ValueError): """Raised if the user provides incorrect script section or kind.""" +@dataclass class Script: """Describes a script based on an entry point declaration.""" - __slots__ = ("name", "module", "attr", "section") + name: str + """Name of the script.""" - def __init__( - self, name: str, module: str, attr: str, section: "ScriptSection" - ) -> None: - """Construct a Script object. + module: str + """Module path, to load the entry point from.""" - :param name: name of the script - :param module: module path, to load the entry point from - :param attr: final attribute access, for the entry point - :param section: Denotes the "entry point section" where this was specified. - Valid values are ``"gui"`` and ``"console"``. - :type section: str + attr: str + """Final attribute access, for the entry point.""" - """ - self.name = name - self.module = module - self.attr = attr - self.section = section - - def __repr__(self) -> str: - return f"Script(name={self.name!r}, module={self.module!r}, attr={self.attr!r}" + section: "ScriptSection" = field(repr=False) + """ + Denotes the "entry point section" where this was specified. Valid values + are ``"gui"`` and ``"console"``. + """ def _get_launcher_data(self, kind: "LauncherKind") -> Optional[bytes]: if kind == "posix":