diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e080e7a..1174e8e 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -36,7 +36,11 @@ jobs: - name: Pre-Tests code smell validation run: | pycodestyle -v --ignore=E501 bitmath/__init__.py tests/ - flake8 --select=F bitmath/__init__.py tests/ + + - name: Lint with pylint + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + run: | + pylint bitmath/__init__.py - name: Run Unit Tests run: | diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..39eb0c5 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright © 2014-2026 Tim Case (fmr. tbielawa) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile index 2a90cd3..43e53de 100644 --- a/Makefile +++ b/Makefile @@ -209,12 +209,12 @@ ci-pycodestyle: @echo "#############################################" . $(NAME)env3/bin/activate && pycodestyle -v --ignore=E501 bitmath/__init__.py tests/*.py -ci-flake8: +ci-pylint: @echo "" @echo "#################################################" - @echo "# Running Flake8 Compliance Tests in virtualenv" + @echo "# Running pylint in virtualenv" @echo "#################################################" - . $(NAME)env3/bin/activate && flake8 --select=F bitmath/__init__.py tests/*.py + . $(NAME)env3/bin/activate && pylint bitmath/__init__.py -ci: clean uniquetestnames virtualenv ci-list-deps ci-pycodestyle ci-flake8 ci-unittests +ci: clean uniquetestnames virtualenv ci-list-deps ci-pycodestyle ci-pylint ci-unittests : diff --git a/bitmath.1 b/bitmath.1 index 8fb8fe8..3bbd11f 100644 --- a/bitmath.1 +++ b/bitmath.1 @@ -2,12 +2,12 @@ .\" Title: bitmath .\" Author: [see the "AUTHOR" section] .\" Generator: DocBook XSL Stylesheets vsnapshot -.\" Date: 04/17/2026 +.\" Date: 05/04/2026 .\" Manual: python-bitmath -.\" Source: bitmath 2.0.0 +.\" Source: bitmath 2.0.1 .\" Language: English .\" -.TH "BITMATH" "1" "04/17/2026" "bitmath 2\&.0\&.0" "python\-bitmath" +.TH "BITMATH" "1" "05/04/2026" "bitmath 2\&.0\&.1" "python\-bitmath" .\" ----------------------------------------------------------------- .\" * Define some portability stuff .\" ----------------------------------------------------------------- diff --git a/bitmath/__init__.py b/bitmath/__init__.py index e69c4a5..b686bfe 100644 --- a/bitmath/__init__.py +++ b/bitmath/__init__.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014-2026 Tim Case -# See GitHub Contributors Graph for more information +# SPDX-FileCopyrightText: 2014-2026 Tim Case +# SPDX-FileCopyrightText: See GitHub Contributors Graph for more information # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -24,22 +24,18 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +# # pylint: disable=line-too-long """Reference material: The bitmath homepage is located at: -* http://bitmath.readthedocs.io/en/latest/ +* https://bitmath.readthedocs.io/en/latest/ Prefixes for binary multiples: -http://physics.nist.gov/cuu/Units/binary.html +https://physics.nist.gov/cuu/Units/binary.html decimal and binary prefixes: man 7 units (from the Linux Documentation Project 'man-pages' package) - - -* If you *NEED* to skip a statement because of something untestable: - - # pragma: no cover """ from __future__ import annotations @@ -54,8 +50,10 @@ import platform import re import shutil +import struct import sys import threading +import warnings from collections.abc import Generator, Iterable, Iterator from typing import IO, Any, NamedTuple, Union @@ -64,11 +62,10 @@ if os.name == 'posix': import stat import fcntl - import struct -elif os.name == 'nt': +elif os.name == 'nt': # pragma: no cover import ctypes import ctypes.wintypes - import msvcrt + import msvcrt # pylint: disable=import-error #: Platforms where :func:`query_device_capacity` is supported. #: Corresponds to possible values of :data:`os.name`. macOS (Darwin) @@ -184,13 +181,13 @@ def capitalize_first(s: str) -> str: ###################################################################### # Base class for everything else -class Bitmath: +class Bitmath: # pylint: disable=too-many-public-methods,too-many-instance-attributes """The base class for all the other prefix classes""" # All the allowed input types valid_types: tuple[type, ...] = (int, float) - def __init__(self, value=0, bytes=None, bits=None): + def __init__(self, value=0, bytes=None, bits=None): # pylint: disable=redefined-builtin """Instantiate with `value` by the unit, in plain bytes, or bits. Don't supply more than one keyword. @@ -312,13 +309,12 @@ def system(self): """The system of units used to measure an instance""" if self._base == 2: return "NIST" - elif self._base == 10: + if self._base == 10: return "SI" - else: - # I don't expect to ever encounter this logic branch, but - # hey, it's better to have extra test coverage than - # insufficient test coverage. - raise ValueError(f"Instances mathematical base is an unsupported value: {self._base}") + # I don't expect to ever encounter this logic branch, but + # hey, it's better to have extra test coverage than + # insufficient test coverage. + raise ValueError(f"Instances mathematical base is an unsupported value: {self._base}") @property def unit(self): @@ -338,12 +334,11 @@ def unit(self): if self.prefix_value == 1: # If it's a '1', return it singular, no matter what return self._name_singular - elif _get_format_plural(): + if _get_format_plural(): # Pluralization requested return self._name_plural - else: - # Pluralization NOT requested, and the value is not 1 - return self._name_singular + # Pluralization NOT requested, and the value is not 1 + return self._name_singular @property def unit_plural(self): @@ -400,8 +395,7 @@ def from_other(cls, item): """ if isinstance(item, Bitmath): return cls(bits=item.bits) - else: - raise ValueError(f"The provided items must be a valid bitmath class: {item.__class__}") + raise ValueError(f"The provided items must be a valid bitmath class: {item.__class__}") ###################################################################### # The following implement the Python datamodel customization methods @@ -507,74 +501,57 @@ def best_prefix(self, system=None): """ + def _resolve_prefix_table(pref): + """Return (prefixes, base) for the given system preference.""" + if pref is None: + if self.system == 'NIST': + return NIST_PREFIXES, 1024 + return SI_PREFIXES, 1000 + if pref == NIST: + return NIST_PREFIXES, 1024 + if pref == SI: + return SI_PREFIXES, 1000 + raise ValueError("Invalid value given for 'system' parameter. Must be one of NIST or SI") + # Use absolute value so we don't return Bit's for *everything* # less than Byte(1). From github issue #55 if abs(self) < Byte(1): return Bit.from_other(self) - else: - if isinstance(self, Byte): - _inst = self - else: - _inst = Byte.from_other(self) - - # Which table to consult? Was a preferred system provided? - if system is None: - # No preference. Use existing system - if self.system == 'NIST': - _STEPS = NIST_PREFIXES - _BASE = 1024 - elif self.system == 'SI': - _STEPS = SI_PREFIXES - _BASE = 1000 - # Anything else would have raised by now - else: - # Preferred system provided. - if system == NIST: - _STEPS = NIST_PREFIXES - _BASE = 1024 - elif system == SI: - _STEPS = SI_PREFIXES - _BASE = 1000 - else: - raise ValueError("Invalid value given for 'system' parameter." - " Must be one of NIST or SI") + _inst = self if isinstance(self, Byte) else Byte.from_other(self) + _steps, _base = _resolve_prefix_table(system) # Index of the string of the best prefix in the STEPS list - _index = int(math.log(abs(_inst.bytes), _BASE)) + _index = int(math.log(abs(_inst.bytes), _base)) # Recall that the log() function returns >= 0. This doesn't # map to the STEPS list 1:1. That is to say, 0 is handled with # special care. So if the _index is 1, we actually want item 0 # in the list. - if _index == 0: # Below the first prefix threshold. Bit-family inputs return as # Bit to preserve family; Byte-family inputs return as Byte. if isinstance(self, Bit): return Bit.from_other(self) return _inst - elif _index >= len(_STEPS): - # This is a really big number. Use the biggest prefix we've got - _best_prefix = _STEPS[-1] - elif 0 < _index < len(_STEPS): - # There is an appropriate prefix unit to represent this - _best_prefix = _STEPS[_index - 1] + + # Default to the largest prefix; override if a more fitting one exists. + _best_prefix = _steps[-1] + if 0 < _index < len(_steps): + _best_prefix = _steps[_index - 1] # Preserve unit family: Bit-family -> 'to_Xib'/'to_Xb', # Byte-family -> 'to_XiB'/'to_XB'. - if isinstance(self, Bit): - _conversion_method = getattr(self, 'to_%sb' % _best_prefix) - else: - _conversion_method = getattr(self, 'to_%sB' % _best_prefix) - - return _conversion_method() + suffix = 'b' if isinstance(self, Bit) else 'B' + return getattr(self, f'to_{_best_prefix}{suffix}')() ################################################################## def to_Bit(self): + """Convert to Bit.""" return Bit(self._bit_value) def to_Byte(self): + """Convert to Byte.""" return Byte(self._byte_value / float(NIST_STEPS['Byte'])) # Properties @@ -584,15 +561,19 @@ def to_Byte(self): ################################################################## def to_KiB(self): + """Convert to KiB.""" return KiB(bits=self._bit_value) def to_Kib(self): + """Convert to Kib.""" return Kib(bits=self._bit_value) def to_kB(self): + """Convert to kB.""" return kB(bits=self._bit_value) def to_kb(self): + """Convert to kb.""" return kb(bits=self._bit_value) # Properties @@ -604,15 +585,19 @@ def to_kb(self): ################################################################## def to_MiB(self): + """Convert to MiB.""" return MiB(bits=self._bit_value) def to_Mib(self): + """Convert to Mib.""" return Mib(bits=self._bit_value) def to_MB(self): + """Convert to MB.""" return MB(bits=self._bit_value) def to_Mb(self): + """Convert to Mb.""" return Mb(bits=self._bit_value) # Properties @@ -624,15 +609,19 @@ def to_Mb(self): ################################################################## def to_GiB(self): + """Convert to GiB.""" return GiB(bits=self._bit_value) def to_Gib(self): + """Convert to Gib.""" return Gib(bits=self._bit_value) def to_GB(self): + """Convert to GB.""" return GB(bits=self._bit_value) def to_Gb(self): + """Convert to Gb.""" return Gb(bits=self._bit_value) # Properties @@ -644,15 +633,19 @@ def to_Gb(self): ################################################################## def to_TiB(self): + """Convert to TiB.""" return TiB(bits=self._bit_value) def to_Tib(self): + """Convert to Tib.""" return Tib(bits=self._bit_value) def to_TB(self): + """Convert to TB.""" return TB(bits=self._bit_value) def to_Tb(self): + """Convert to Tb.""" return Tb(bits=self._bit_value) # Properties @@ -664,15 +657,19 @@ def to_Tb(self): ################################################################## def to_PiB(self): + """Convert to PiB.""" return PiB(bits=self._bit_value) def to_Pib(self): + """Convert to Pib.""" return Pib(bits=self._bit_value) def to_PB(self): + """Convert to PB.""" return PB(bits=self._bit_value) def to_Pb(self): + """Convert to Pb.""" return Pb(bits=self._bit_value) # Properties @@ -684,15 +681,19 @@ def to_Pb(self): ################################################################## def to_EiB(self): + """Convert to EiB.""" return EiB(bits=self._bit_value) def to_Eib(self): + """Convert to Eib.""" return Eib(bits=self._bit_value) def to_EB(self): + """Convert to EB.""" return EB(bits=self._bit_value) def to_Eb(self): + """Convert to Eb.""" return Eb(bits=self._bit_value) # Properties @@ -704,15 +705,19 @@ def to_Eb(self): ################################################################## def to_ZiB(self): + """Convert to ZiB.""" return ZiB(bits=self._bit_value) def to_Zib(self): + """Convert to Zib.""" return Zib(bits=self._bit_value) def to_ZB(self): + """Convert to ZB.""" return ZB(bits=self._bit_value) def to_Zb(self): + """Convert to Zb.""" return Zb(bits=self._bit_value) ZiB = property(lambda s: s.to_ZiB()) @@ -723,15 +728,19 @@ def to_Zb(self): ################################################################## def to_YiB(self): + """Convert to YiB.""" return YiB(bits=self._bit_value) def to_Yib(self): + """Convert to Yib.""" return Yib(bits=self._bit_value) def to_YB(self): + """Convert to YB.""" return YB(bits=self._bit_value) def to_Yb(self): + """Convert to Yb.""" return Yb(bits=self._bit_value) YiB = property(lambda s: s.to_YiB()) @@ -746,38 +755,32 @@ def to_Yb(self): def __lt__(self, other): if isinstance(other, numbers.Number): return self.prefix_value < other - else: - return self._byte_value < other.bytes + return self._byte_value < other.bytes def __le__(self, other): if isinstance(other, numbers.Number): return self.prefix_value <= other - else: - return self._byte_value <= other.bytes + return self._byte_value <= other.bytes def __eq__(self, other): if isinstance(other, numbers.Number): return self.prefix_value == other - else: - return self._byte_value == other.bytes + return self._byte_value == other.bytes def __ne__(self, other): if isinstance(other, numbers.Number): return self.prefix_value != other - else: - return self._byte_value != other.bytes + return self._byte_value != other.bytes def __gt__(self, other): if isinstance(other, numbers.Number): return self.prefix_value > other - else: - return self._byte_value > other.bytes + return self._byte_value > other.bytes def __ge__(self, other): if isinstance(other, numbers.Number): return self.prefix_value >= other - else: - return self._byte_value >= other.bytes + return self._byte_value >= other.bytes ################################################################## # Basic math operations @@ -795,10 +798,9 @@ def __add__(self, other): if isinstance(other, numbers.Number): # bm + num return other + self.value - else: - # bm + bm - total_bytes = self._byte_value + other.bytes - return (type(self))(bytes=total_bytes) + # bm + bm + total_bytes = self._byte_value + other.bytes + return (type(self))(bytes=total_bytes) def __sub__(self, other): """Subtraction: Supported operations with result types: @@ -810,10 +812,9 @@ def __sub__(self, other): if isinstance(other, numbers.Number): # bm - num return self.value - other - else: - # bm - bm - total_bytes = self._byte_value - other.bytes - return (type(self))(bytes=total_bytes) + # bm - bm + total_bytes = self._byte_value - other.bytes + return (type(self))(bytes=total_bytes) def __mul__(self, other): """Multiplication: Supported operations with result types: @@ -826,11 +827,10 @@ def __mul__(self, other): # bm * num result = self._byte_value * other return (type(self))(bytes=result) - else: - # bm1 * bm2 - _other = other.value * other.base ** other.power - _self = self.prefix_value * self._base ** self._power - return (type(self))(bytes=_other * _self) + # bm1 * bm2 + _other = other.value * other.base ** other.power + _self = self.prefix_value * self._base ** self._power + return (type(self))(bytes=_other * _self) def __truediv__(self, other): """Division: Supported operations with result types: @@ -843,9 +843,8 @@ def __truediv__(self, other): # bm / num result = self._byte_value / other return (type(self))(bytes=result) - else: - # bm1 / bm2 - return self._byte_value / float(other.bytes) + # bm1 / bm2 + return self._byte_value / float(other.bytes) def __floordiv__(self, other): """Floor division: Supported operations with result types: @@ -857,9 +856,8 @@ def __floordiv__(self, other): # bm // num result = self._byte_value // other return (type(self))(bytes=result) - else: - # bm1 // bm2 - return int(self._byte_value // other.bytes) + # bm1 // bm2 + return int(self._byte_value // other.bytes) def __mod__(self, other): """Modulo (remainder): Supported operations with result types: @@ -871,9 +869,8 @@ def __mod__(self, other): # bm % num result = self._byte_value % other return (type(self))(bytes=result) - else: - # bm1 % bm2 - return (type(self))(bytes=self._byte_value % other.bytes) + # bm1 % bm2 + return (type(self))(bytes=self._byte_value % other.bytes) def __divmod__(self, other): """divmod(bm, other) == (bm // other, bm % other). @@ -903,18 +900,10 @@ def __rtruediv__(self, other): # num / bm = num return other / float(self.value) - """Called to implement the built-in functions complex(), int(), and -float(). Should return a value of the appropriate type. - -If one of those methods does not support the operation with the -supplied arguments, it should return NotImplemented. - -For bitmath purposes, these methods return the int/float -equivalent of the this instances prefix Unix value. That is to say: - - - int(KiB(3.336)) would return 3 - - float(KiB(3.336)) would return 3.336 -""" + # Called to implement the built-in functions complex(), int(), and + # float(). These return the int/float equivalent of the prefix value: + # - int(KiB(3.336)) -> 3 + # - float(KiB(3.336)) -> 3.336 def __int__(self) -> int: """Return this instances prefix unit as an integer""" @@ -924,11 +913,9 @@ def __float__(self) -> float: """Return this instances prefix unit as a floating point number""" return float(self.prefix_value) - """floor/ceil/round operate on the prefix value and return the same unit -type. They are explicit opt-in operations for when integer prefix values are -needed. See the Rules for Math appendix in the bitmath documentation for the -design rationale behind floating-point representation. -""" + # floor/ceil/round operate on the prefix value and return the same unit + # type. Explicit opt-in for integer prefix values. See the Rules for Math + # appendix in the docs for the floating-point design rationale. def __floor__(self): """Return the largest integer prefix value <= this instance as the same type. @@ -999,8 +986,8 @@ def __or__(self, other): Does a "bitwise or". Each bit of the output is 0 if the corresponding bit of x AND of y is 0, otherwise it's 1.""" - ord = int(self.bits) | other - return type(self)(bits=ord) + result = int(self.bits) | other + return type(self)(bits=result) ################################################################## @@ -1033,6 +1020,8 @@ def _setup(self): class KiB(Byte): + """Kibibyte — 2^10 (1,024) bytes.""" + def _setup(self): return (2, 10, 'KiB', 'KiBs') @@ -1041,6 +1030,8 @@ def _setup(self): class MiB(Byte): + """Mebibyte — 2^20 (1,048,576) bytes.""" + def _setup(self): return (2, 20, 'MiB', 'MiBs') @@ -1049,6 +1040,8 @@ def _setup(self): class GiB(Byte): + """Gibibyte — 2^30 (1,073,741,824) bytes.""" + def _setup(self): return (2, 30, 'GiB', 'GiBs') @@ -1057,6 +1050,8 @@ def _setup(self): class TiB(Byte): + """Tebibyte — 2^40 bytes.""" + def _setup(self): return (2, 40, 'TiB', 'TiBs') @@ -1065,6 +1060,8 @@ def _setup(self): class PiB(Byte): + """Pebibyte — 2^50 bytes.""" + def _setup(self): return (2, 50, 'PiB', 'PiBs') @@ -1073,6 +1070,8 @@ def _setup(self): class EiB(Byte): + """Exbibyte — 2^60 bytes.""" + def _setup(self): return (2, 60, 'EiB', 'EiBs') @@ -1081,6 +1080,8 @@ def _setup(self): class ZiB(Byte): + """Zebibyte — 2^70 bytes.""" + def _setup(self): return (2, 70, 'ZiB', 'ZiBs') @@ -1089,6 +1090,8 @@ def _setup(self): class YiB(Byte): + """Yobibyte — 2^80 bytes.""" + def _setup(self): return (2, 80, 'YiB', 'YiBs') @@ -1099,6 +1102,8 @@ def _setup(self): ###################################################################### # SI Prefixes for Byte based types class kB(Byte): + """Kilobyte — 10^3 (1,000) bytes.""" + def _setup(self): return (10, 3, 'kB', 'kBs') @@ -1107,6 +1112,8 @@ def _setup(self): class MB(Byte): + """Megabyte — 10^6 (1,000,000) bytes.""" + def _setup(self): return (10, 6, 'MB', 'MBs') @@ -1115,6 +1122,8 @@ def _setup(self): class GB(Byte): + """Gigabyte — 10^9 (1,000,000,000) bytes.""" + def _setup(self): return (10, 9, 'GB', 'GBs') @@ -1123,6 +1132,8 @@ def _setup(self): class TB(Byte): + """Terabyte — 10^12 bytes.""" + def _setup(self): return (10, 12, 'TB', 'TBs') @@ -1131,6 +1142,8 @@ def _setup(self): class PB(Byte): + """Petabyte — 10^15 bytes.""" + def _setup(self): return (10, 15, 'PB', 'PBs') @@ -1139,6 +1152,8 @@ def _setup(self): class EB(Byte): + """Exabyte — 10^18 bytes.""" + def _setup(self): return (10, 18, 'EB', 'EBs') @@ -1147,6 +1162,8 @@ def _setup(self): class ZB(Byte): + """Zettabyte — 10^21 bytes.""" + def _setup(self): return (10, 21, 'ZB', 'ZBs') @@ -1155,6 +1172,8 @@ def _setup(self): class YB(Byte): + """Yottabyte — 10^24 bytes.""" + def _setup(self): return (10, 24, 'YB', 'YBs') @@ -1183,41 +1202,57 @@ def _norm(self, value): ###################################################################### # NIST Prefixes for Bit based types class Kib(Bit): + """Kibibit — 2^10 (1,024) bits.""" + def _setup(self): return (2, 10, 'Kib', 'Kibs') class Mib(Bit): + """Mebibit — 2^20 (1,048,576) bits.""" + def _setup(self): return (2, 20, 'Mib', 'Mibs') class Gib(Bit): + """Gibibit — 2^30 (1,073,741,824) bits.""" + def _setup(self): return (2, 30, 'Gib', 'Gibs') class Tib(Bit): + """Tebibit — 2^40 bits.""" + def _setup(self): return (2, 40, 'Tib', 'Tibs') class Pib(Bit): + """Pebibit — 2^50 bits.""" + def _setup(self): return (2, 50, 'Pib', 'Pibs') class Eib(Bit): + """Exbibit — 2^60 bits.""" + def _setup(self): return (2, 60, 'Eib', 'Eibs') class Zib(Bit): + """Zebibit — 2^70 bits.""" + def _setup(self): return (2, 70, 'Zib', 'Zibs') class Yib(Bit): + """Yobibit — 2^80 bits.""" + def _setup(self): return (2, 80, 'Yib', 'Yibs') @@ -1225,48 +1260,64 @@ def _setup(self): ###################################################################### # SI Prefixes for Bit based types class kb(Bit): + """Kilobit — 10^3 (1,000) bits.""" + def _setup(self): return (10, 3, 'kb', 'kbs') class Mb(Bit): + """Megabit — 10^6 (1,000,000) bits.""" + def _setup(self): return (10, 6, 'Mb', 'Mbs') class Gb(Bit): + """Gigabit — 10^9 (1,000,000,000) bits.""" + def _setup(self): return (10, 9, 'Gb', 'Gbs') class Tb(Bit): + """Terabit — 10^12 bits.""" + def _setup(self): return (10, 12, 'Tb', 'Tbs') class Pb(Bit): + """Petabit — 10^15 bits.""" + def _setup(self): return (10, 15, 'Pb', 'Pbs') class Eb(Bit): + """Exabit — 10^18 bits.""" + def _setup(self): return (10, 18, 'Eb', 'Ebs') class Zb(Bit): + """Zettabit — 10^21 bits.""" + def _setup(self): return (10, 21, 'Zb', 'Zbs') class Yb(Bit): + """Yottabit — 10^24 bits.""" + def _setup(self): return (10, 24, 'Yb', 'Ybs') ###################################################################### # Utility functions -def best_prefix(bytes: Bitmath | int | float, system: int = NIST) -> Bitmath: +def best_prefix(bytes: Bitmath | int | float, system: int = NIST) -> Bitmath: # pylint: disable=redefined-builtin """Return a bitmath instance representing the best human-readable representation of the number of bytes given by ``bytes``. In addition to a numeric type, the ``bytes`` parameter may also be a bitmath type. @@ -1303,9 +1354,11 @@ def _query_device_capacity_windows(device_fd: IO[Any]) -> int: if not device_fd.name.startswith('\\\\.\\'): raise ValueError("The file descriptor provided is not of a device type") + # pylint: disable=used-before-assignment # ctypes/msvcrt: platform-guarded; only reached on os.name == 'nt' IOCTL_DISK_GET_DRIVE_GEOMETRY_EX = 0x000700A0 - class DISK_GEOMETRY(ctypes.Structure): + class DISK_GEOMETRY(ctypes.Structure): # pylint: disable=too-few-public-methods + """Windows API DISK_GEOMETRY structure (DeviceIoControl layout).""" _fields_ = [ ('Cylinders', ctypes.c_longlong), ('MediaType', ctypes.c_uint), @@ -1314,7 +1367,8 @@ class DISK_GEOMETRY(ctypes.Structure): ('BytesPerSector', ctypes.c_ulong), ] - class DISK_GEOMETRY_EX(ctypes.Structure): + class DISK_GEOMETRY_EX(ctypes.Structure): # pylint: disable=too-few-public-methods + """Extended disk geometry including total disk size.""" _fields_ = [ ('Geometry', DISK_GEOMETRY), ('DiskSize', ctypes.c_longlong), @@ -1372,7 +1426,7 @@ def query_device_capacity(device_fd: IO[Any]) -> Byte: ) s = os.stat(device_fd.name).st_mode - if not stat.S_ISBLK(s): + if not stat.S_ISBLK(s): # pylint: disable=possibly-used-before-assignment raise ValueError("The file descriptor provided is not of a device type") # The keys of the ``ioctl_map`` dictionary correlate to possible @@ -1428,7 +1482,9 @@ def query_device_capacity(device_fd: IO[Any]) -> Byte: # conditions for some possible errors. Really only for cases # where it would add value to override the default exception # message string. - buffer = fcntl.ioctl(device_fd.fileno(), request_code, b'\x00' * buffer_size) + buffer = fcntl.ioctl( # pylint: disable=possibly-used-before-assignment + device_fd.fileno(), request_code, b'\x00' * buffer_size + ) # Unpack the raw result from the ioctl call into a familiar # python data type according to the ``fmt`` rules. @@ -1517,17 +1573,17 @@ def getsize(path: str, bestprefix: bool = True, system: int = NIST) -> Bitmath: size_bytes = os.path.getsize(_path) if bestprefix: return Byte(size_bytes).best_prefix(system=system) - else: - return Byte(size_bytes) + return Byte(size_bytes) -def listdir( +def listdir( # pylint: disable=too-many-arguments,too-many-positional-arguments search_base: str, followlinks: bool = False, - filter: str = '*', + glob: str = '*', relpath: bool = False, bestprefix: bool = False, system: int = NIST, + **kwargs, ) -> Iterator[tuple[str, Bitmath]]: """This is a generator which recurses the directory tree `search_base`, yielding 2-tuples of: @@ -1537,7 +1593,7 @@ def listdir( - `search_base` - The directory to begin walking down. - `followlinks` - Whether or not to follow symbolic links to directories - - `filter` - A glob (see :py:mod:`fnmatch`) to filter results with + - `glob` - A glob (see :py:mod:`fnmatch`) to filter results with (default: ``*``, everything) - `relpath` - ``True`` to return the relative path from `pwd` or ``False`` (default) to return the fully qualified path @@ -1551,8 +1607,18 @@ def listdir( .. note:: Symlinks to **files** are followed automatically """ - for root, dirs, files in os.walk(search_base, followlinks=followlinks): - for name in fnmatch.filter(files, filter): + if 'filter' in kwargs: + warnings.warn( + "The 'filter' parameter of listdir() is deprecated as of 2.0.0 and will be " + "removed in a future release. Use 'glob' instead.", + DeprecationWarning, + stacklevel=2, + ) + glob = kwargs.pop('filter') + if kwargs: + raise TypeError(f"listdir() got unexpected keyword arguments: {list(kwargs)}") + for root, _, files in os.walk(search_base, followlinks=followlinks): + for name in fnmatch.filter(files, glob): _path = os.path.join(root, name) if relpath: # RELATIVE path @@ -1570,6 +1636,99 @@ def listdir( yield (_return_path, getsize(_path, bestprefix=bestprefix, system=system)) +def _parse_string_strict(s: str) -> 'Bitmath': + if not isinstance(s, str): + raise ValueError(f"parse_string only accepts string inputs but a {type(s)} was given") + + # get the index of the first alphabetic character + try: + index = next(i for i, c in enumerate(s) if c.isalpha()) + except StopIteration: + raise ValueError(f"No unit detected, can not parse string '{s}' into a bitmath object") from None + + # split the string into the value and the unit + val, unit = s[:index], s[index:] + + # see if the unit exists as a type in our namespace + if unit == "b": + unit_class = Bit + elif unit == "B": + unit_class = Byte + else: + if not (hasattr(sys.modules[__name__], unit) and isinstance(getattr(sys.modules[__name__], unit), type)): + raise ValueError(f"The unit {unit} is not a valid bitmath unit") + unit_class = globals()[unit] + + val = float(val) + return unit_class(val) + + +def _parse_string_unsafe(s: str | numbers.Number, system: int) -> 'Bitmath': # pylint: disable=too-many-branches + if not isinstance(s, str) and not isinstance(s, numbers.Number): + raise ValueError(f"parse_string only accepts string/number inputs but a {type(s)} was given") + + # Test case: raw number input (easy!) + if isinstance(s, numbers.Number): + return Byte(s) + + # Test case: a number pretending to be a string + if isinstance(s, str): + try: + return Byte(float(s)) + except ValueError: + pass + + # At this point the input is a string with a unit component. + # Separate the number and the unit. + try: + index = next(i for i, c in enumerate(s) if c.isalpha()) + except StopIteration: # pragma: no cover + raise ValueError(f"No unit detected, can not parse string '{s}' into a bitmath object") from None + + val, unit = s[:index], s[index:] + + # Explicit base-unit and word-form checks: handle B, b, bit(s), + # byte(s) before the prefix-normalization logic below. + _unit_lower = unit.lower() + if unit == 'B' or _unit_lower in ('byte', 'bytes'): + return Byte(float(val)) + if unit == 'b' or _unit_lower in ('bit', 'bits'): + return Bit(float(val)) + + # Normalise: strip trailing b/B and append 'B' so we always + # work with byte-family units regardless of what was supplied. + unit = unit.rstrip('Bb') + unit += 'B' + + unit_class = None + if len(unit) == 2: + if system == NIST: + unit = capitalize_first(unit) + _unit = list(unit) + _unit.insert(1, 'i') + unit = ''.join(_unit) + if unit in globals(): + unit_class = globals()[unit] + else: + if unit.startswith('K'): + unit = unit.replace('K', 'k') + elif not unit.startswith('k'): + unit = capitalize_first(unit) + if unit[0] in SI_PREFIXES: + unit_class = globals()[unit] + elif len(unit) == 3: + unit = capitalize_first(unit) + if unit[:2] in NIST_PREFIXES: + unit_class = globals()[unit] + else: + raise ValueError(f"The unit {unit} is not a valid bitmath unit") + + if unit_class is None: + raise ValueError(f"The unit {unit} is not a valid bitmath unit") + + return unit_class(float(val)) + + def parse_string(s: str | numbers.Number, system: int = NIST, strict: bool = True) -> Bitmath: """Parse a string with units and return a bitmath instance. @@ -1610,102 +1769,8 @@ def parse_string(s: str | numbers.Number, system: int = NIST, strict: bool = Tru defaults to ``bitmath.NIST`` and is ignored when ``strict=True``. """ if strict: - # Strings only please - if not isinstance(s, str): - raise ValueError(f"parse_string only accepts string inputs but a {type(s)} was given") - - # get the index of the first alphabetic character - try: - index = next(i for i, c in enumerate(s) if c.isalpha()) - except StopIteration: - # If there's no alphabetic characters we won't be able to find a match - raise ValueError(f"No unit detected, can not parse string '{s}' into a bitmath object") - - # split the string into the value and the unit - val, unit = s[:index], s[index:] - - # see if the unit exists as a type in our namespace - if unit == "b": - unit_class = Bit - elif unit == "B": - unit_class = Byte - else: - if not (hasattr(sys.modules[__name__], unit) and isinstance(getattr(sys.modules[__name__], unit), type)): - raise ValueError(f"The unit {unit} is not a valid bitmath unit") - unit_class = globals()[unit] - - try: - val = float(val) - except ValueError: - raise - return unit_class(val) - - else: - # strict=False path (formerly parse_string_unsafe) - if not isinstance(s, str) and not isinstance(s, numbers.Number): - raise ValueError(f"parse_string only accepts string/number inputs but a {type(s)} was given") - - # Test case: raw number input (easy!) - if isinstance(s, numbers.Number): - return Byte(s) - - # Test case: a number pretending to be a string - if isinstance(s, str): - try: - return Byte(float(s)) - except ValueError: - pass - - # At this point the input is a string with a unit component. - # Separate the number and the unit. - try: - index = next(i for i, c in enumerate(s) if c.isalpha()) - except StopIteration: # pragma: no cover - raise ValueError(f"No unit detected, can not parse string '{s}' into a bitmath object") - - val, unit = s[:index], s[index:] - - # Explicit base-unit and word-form checks: handle B, b, bit(s), - # byte(s) before the prefix-normalization logic below. - _unit_lower = unit.lower() - if unit == 'B' or _unit_lower in ('byte', 'bytes'): - return Byte(float(val)) - if unit == 'b' or _unit_lower in ('bit', 'bits'): - return Bit(float(val)) - - # Normalise: strip trailing b/B and append 'B' so we always - # work with byte-family units regardless of what was supplied. - unit = unit.rstrip('Bb') - unit += 'B' - - if len(unit) == 2: - if system == NIST: - unit = capitalize_first(unit) - _unit = list(unit) - _unit.insert(1, 'i') - unit = ''.join(_unit) - if unit in globals(): - unit_class = globals()[unit] - else: - if unit.startswith('K'): - unit = unit.replace('K', 'k') - elif not unit.startswith('k'): - unit = capitalize_first(unit) - if unit[0] in SI_PREFIXES: - unit_class = globals()[unit] - elif len(unit) == 3: - unit = capitalize_first(unit) - if unit[:2] in NIST_PREFIXES: - unit_class = globals()[unit] - else: - raise ValueError(f"The unit {unit} is not a valid bitmath unit") - - try: - unit_class - except UnboundLocalError: - raise ValueError(f"The unit {unit} is not a valid bitmath unit") - - return unit_class(float(val)) + return _parse_string_strict(s) + return _parse_string_unsafe(s, system) def parse_string_unsafe(s: str | numbers.Number, system: int = NIST) -> Bitmath: @@ -1722,7 +1787,6 @@ def parse_string_unsafe(s: str | numbers.Number, system: int = NIST) -> Bitmath: warnings.filterwarnings('ignore', category=DeprecationWarning, module='bitmath') """ - import warnings warnings.warn( "parse_string_unsafe is deprecated as of 2.0.0 and will be removed " "in a future release. Use parse_string(s, strict=False, system=system) " @@ -1734,7 +1798,7 @@ def parse_string_unsafe(s: str | numbers.Number, system: int = NIST) -> Bitmath: return parse_string(s, system=system, strict=False) -def sum(iterable: Iterable[Bitmath], start: Bitmath | None = None) -> Bitmath: +def sum(iterable: Iterable[Bitmath], start: Bitmath | None = None) -> Bitmath: # pylint: disable=redefined-builtin """Sum an iterable of bitmath instances, returning a Byte by default. The built-in sum() also works with bitmath objects: the __radd__ @@ -1755,7 +1819,7 @@ def sum(iterable: Iterable[Bitmath], start: Bitmath | None = None) -> Bitmath: ###################################################################### # Context Managers @contextlib.contextmanager -def format(fmt_str: str | None = None, plural: bool = False, bestprefix: bool = False) -> Generator[None, None, None]: +def format(fmt_str: str | None = None, plural: bool = False, bestprefix: bool = False) -> Generator[None, None, None]: # pylint: disable=redefined-builtin """Thread-safe context manager for printing bitmath instances. ``fmt_str`` - a formatting mini-language compatible string. See @@ -1846,8 +1910,7 @@ def cli_script_main(cli_args): def cli_script(): # pragma: no cover - # Wrapper around cli_script_main so we can unittest the command - # line functionality + """Entry point for the bitmath CLI; wraps cli_script_main for testability.""" for result in cli_script_main(sys.argv[1:]): print(result) diff --git a/docsite/source/contributing.rst b/docsite/source/contributing.rst index 92cf61a..f8aafb3 100644 --- a/docsite/source/contributing.rst +++ b/docsite/source/contributing.rst @@ -39,13 +39,15 @@ provided template. Code Style and Formatting ************************* -Two static analysis checks run on every pull request as part of the +Two static analysis tools run on every pull request as part of the GitHub Actions CI workflow, and locally via ``make ci``: -* ``pycodestyle`` — checks code style, with **E501** (line too long) - ignored. -* ``flake8 --select=F`` — runs pyflakes error checks only (undefined - names, unused imports, etc.). Style checks are disabled. +* ``pycodestyle`` — checks PEP 8 code style, with **E501** (line too + long) ignored. Runs across all matrix cells. +* ``pylint`` — full static analysis: naming conventions, refactoring + hints, design metrics, and correctness checks. The project targets + 10.00/10. Runs on Ubuntu / Python 3.12 only (linting is not + platform-sensitive). A PR cannot be merged until both pass. If you want to save time you can run ``make ci`` locally to check before submitting and waiting on @@ -150,10 +152,11 @@ The bitmath test suite depends on the following tools: can always be discussed in the pull request. * `pycodestyle `_ — Checks - Python code style. + Python code style (PEP 8). -* `pyflakes `_ — Checks Python - source files for errors. +* `pylint `_ — Full static analysis: + naming, refactoring hints, design metrics, and correctness. The + project targets a score of 10.00/10. * `virtualenv `_ — Creates an isolated Python environment. The ``make ci`` target manages this @@ -181,7 +184,7 @@ relevant to contributors are: The primary target. Creates a Python virtualenv, installs all dependencies from ``requirements.txt``, runs the unique test name check, executes the full pytest suite with coverage, and runs - ``pycodestyle`` and ``pyflakes``. Run this before opening a pull + ``pycodestyle`` and ``pylint``. Run this before opening a pull request. **This is the same check GitHub Actions runs.** ``make clean`` diff --git a/docsite/source/module.rst b/docsite/source/module.rst index f0dbbb4..c24c97b 100644 --- a/docsite/source/module.rst +++ b/docsite/source/module.rst @@ -83,7 +83,7 @@ bitmath.getsize() bitmath.listdir() ================= -.. function:: listdir(search_base[, followlinks=False[, filter='*'[, relpath=False[, bestprefix=False[, system=NIST]]]]]) +.. function:: listdir(search_base[, followlinks=False[, glob='*'[, relpath=False[, bestprefix=False[, system=NIST]]]]]) This is a `generator `_ @@ -97,10 +97,10 @@ bitmath.listdir() links. Whether or not to follow symbolic links to directories. Setting to ``True`` enables directory link following - :param string filter: **Default:** ``*`` (everything). A glob to - filter results with. See `fnmatch - `_ - for more details about *globs* + :param string glob: **Default:** ``*`` (everything). A glob to + filter results with. See `fnmatch + `_ + for more details about *globs* :param bool relpath: **Default:** ``False``, returns the fully qualified to each discovered file. ``True`` to return the relative path from the present @@ -195,12 +195,12 @@ bitmath.listdir() on lines **10** and **11** the path is relative to the present working directory. - Let's play with the ``filter`` parameter now. Let's say we only + Let's play with the ``glob`` parameter now. Let's say we only want to include results for files whose name begins with "second": .. code-block:: python - >>> for f in bitmath.listdir('./some_files', filter='second*'): + >>> for f in bitmath.listdir('./some_files', glob='second*'): ... print(f) ... ('/tmp/tmp.P5lqtyqwPh/some_files/deeper_files/second_file', Byte(13370.0)) diff --git a/pyproject.toml b/pyproject.toml index 012e7db..2749282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,3 +80,18 @@ packages = ["bitmath"] [tool.hatch.publish.index] disable = true + +[tool.pylint.main] +py-version = "3.12" + +[tool.pylint.basic] +# format_string / format_plural: public mutable module-level config (users assign these directly) +good-names = ["format_string", "format_plural"] +good-names-rgxs = [ + "^to_[A-Za-z]+$", # unit conversion methods: to_KiB, to_kB, to_Mib, etc. + "^[a-z][A-Za-z]$", # two-char unit class names that start lowercase: kB, kb, ko + "^[A-Z][A-Z_]+$", # Windows API / ctypes names: DISK_GEOMETRY, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX +] + +[tool.pylint.design] +max-module-lines = 2000 diff --git a/requirements.txt b/requirements.txt index ae1ba7e..8e4bceb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -flake8 pycodestyle +pylint pytest pytest-cov diff --git a/tests/__init__.py b/tests/__init__.py index 22a0243..3190583 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_basic_math.py b/tests/test_basic_math.py index d0705c1..21e52c3 100644 --- a/tests/test_basic_math.py +++ b/tests/test_basic_math.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_best_prefix_BASE.py b/tests/test_best_prefix_BASE.py index 8ccc799..f9a1001 100644 --- a/tests/test_best_prefix_BASE.py +++ b/tests/test_best_prefix_BASE.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -99,3 +99,10 @@ def test_best_prefix_negative_huge_numbers(self): self.assertIs(type(negative_result), type(positive_result)) # Verify that type is what we expect it to be self.assertIs(type(negative_result), bitmath.MiB) + + +class TestBestPrefixInvalidSystem(TestCase): + def test_best_prefix_invalid_system_raises(self): + """best_prefix raises ValueError when an invalid system constant is passed""" + with self.assertRaises(ValueError): + bitmath.best_prefix(bitmath.MiB(1), system="bogus") diff --git a/tests/test_best_prefix_NIST.py b/tests/test_best_prefix_NIST.py index 5928572..2f3358a 100644 --- a/tests/test_best_prefix_NIST.py +++ b/tests/test_best_prefix_NIST.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_best_prefix_SI.py b/tests/test_best_prefix_SI.py index 35c80fa..104d4c3 100644 --- a/tests/test_best_prefix_SI.py +++ b/tests/test_best_prefix_SI.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_bitwise_operations.py b/tests/test_bitwise_operations.py index 96cdae5..512abdf 100644 --- a/tests/test_bitwise_operations.py +++ b/tests/test_bitwise_operations.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a0adf1..f5817d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index 1c71a9d..ab0fc77 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_context_manager_thread_safe.py b/tests/test_context_manager_thread_safe.py index c6481b5..583dd7c 100644 --- a/tests/test_context_manager_thread_safe.py +++ b/tests/test_context_manager_thread_safe.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2026 Tim Case +# SPDX-FileCopyrightText: 2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_file_size.py b/tests/test_file_size.py index 9783742..ff08fb1 100644 --- a/tests/test_file_size.py +++ b/tests/test_file_size.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -33,6 +33,7 @@ import bitmath import os import pathlib +import warnings class TestFileSize(TestCase): @@ -241,7 +242,7 @@ def test_listdir_filtering_nosymlinks(self): contents = list(bitmath.listdir('./tests/listdir_nosymlinks/', relpath=True, # Should only find 1 file, 1024_byte_file - filter='1024*')) + glob='1024*')) # Ensure the returned path matches the expected path self.assertEqual(pathlib.Path(contents[0][0]).as_posix(), @@ -258,7 +259,35 @@ def test_listdir_filtering_empty_match_nosymlinks(self): contents = list(bitmath.listdir('./tests/listdir_nosymlinks/', relpath=True, # Should find no matches - filter='*notafile*')) + glob='*notafile*')) # There should be one file discovered self.assertEqual(len(contents), int(0)) + + +class TestListdirDeprecations(TestCase): + def test_listdir_filter_kwarg_emits_deprecation_warning(self): + """listdir() emits DeprecationWarning when called with filter= instead of glob=""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + list(bitmath.listdir('./tests/listdir_nosymlinks/', filter='*')) + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[0].category, DeprecationWarning)) + self.assertIn("filter", str(w[0].message)) + self.assertIn("glob", str(w[0].message)) + + def test_listdir_filter_kwarg_still_works(self): + """listdir() with deprecated filter= kwarg returns correct results""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + contents = list(bitmath.listdir('./tests/listdir_nosymlinks/', + relpath=True, + filter='1024*')) + self.assertEqual(len(contents), 1) + self.assertEqual(pathlib.Path(contents[0][0]).as_posix(), + 'tests/listdir_nosymlinks/depth1/depth2/1024_byte_file') + + def test_listdir_unknown_kwarg_raises_typeerror(self): + """listdir() raises TypeError for unrecognized keyword arguments""" + with self.assertRaises(TypeError): + list(bitmath.listdir('./tests/listdir_nosymlinks/', notaarg=True)) diff --git a/tests/test_future_math.py b/tests/test_future_math.py index 3d4f4b7..43173e8 100644 --- a/tests/test_future_math.py +++ b/tests/test_future_math.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_init.py b/tests/test_init.py index 8b48bac..f0c18eb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2015 Tim Case +# SPDX-FileCopyrightText: 2015-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_instantiating.py b/tests/test_instantiating.py index 6269760..70618e4 100644 --- a/tests/test_instantiating.py +++ b/tests/test_instantiating.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_parse_strict.py b/tests/test_parse_strict.py new file mode 100644 index 0000000..7cf60f5 --- /dev/null +++ b/tests/test_parse_strict.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: MIT +# The MIT License (MIT) +# +# SPDX-FileCopyrightText: 2014-2026 Tim Case +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +""" +Tests for parse_string (strict=True, the default) and public entry-point behaviour. +""" + +import warnings + +from . import TestCase +import bitmath + + +class TestParseStrict(TestCase): + def test_parse_b(self): + """parse_string works on bit strings""" + self.assertEqual( + bitmath.parse_string("123b"), + bitmath.Bit(123)) + + def test_parse_B(self): + """parse_string works on byte strings""" + self.assertEqual( + bitmath.parse_string("321B"), + bitmath.Byte(321)) + + def test_parse_Gb(self): + """parse_string works on gigabit strings""" + self.assertEqual( + bitmath.parse_string("456Gb"), + bitmath.Gb(456)) + + def test_parse_MiB(self): + """parse_string works on mebibyte strings""" + self.assertEqual( + bitmath.parse_string("654 MiB"), + bitmath.MiB(654)) + + ###################################################################### + # NIST 'octet' based units + def test_parse_Mio(self): + """parse_string works on mebioctet strings""" + self.assertEqual( + bitmath.parse_string("654 Mio"), + bitmath.MiB(654)) + + def test_parse_Eio(self): + """parse_string works on exbioctet strings""" + self.assertEqual( + bitmath.parse_string("654 Eio"), + bitmath.EiB(654)) + + def test_parse_Zio(self): + """parse_string works on zebioctet strings""" + self.assertEqual( + bitmath.parse_string("654 Zio"), + bitmath.ZiB(654)) + + def test_parse_Yio(self): + """parse_string works on yobioctet strings""" + self.assertEqual( + bitmath.parse_string("654 Yio"), + bitmath.YiB(654)) + + # SI 'octet' based units + def test_parse_Mo(self): + """parse_string works on megaoctet strings""" + self.assertEqual( + bitmath.parse_string("654 Mo"), + bitmath.MB(654)) + + def test_parse_Eo(self): + """parse_string works on exaoctet strings""" + self.assertEqual( + bitmath.parse_string("654 Eo"), + bitmath.EB(654)) + + def test_parse_Zo(self): + """parse_string works on zettaoctet strings""" + self.assertEqual( + bitmath.parse_string("654 Zo"), + bitmath.ZB(654)) + + def test_parse_Yo(self): + """parse_string works on yottaoctet strings""" + self.assertEqual( + bitmath.parse_string("654 Yo"), + bitmath.YB(654)) + + ###################################################################### + + def test_parse_bad_float(self): + """parse_string can identify invalid float values""" + with self.assertRaises(ValueError): + bitmath.parse_string("1.23.45 kb") + + def test_parse_bad_unit(self): + """parse_string can identify invalid prefix units""" + with self.assertRaises(ValueError): + bitmath.parse_string("1.23 GIB") + + def test_parse_bad_unit2(self): + """parse_string can identify other prefix units""" + with self.assertRaises(ValueError): + bitmath.parse_string("1.23 QB") + + def test_parse_no_unit(self): + """parse_string can identify strings without units at all""" + with self.assertRaises(ValueError): + bitmath.parse_string("12345") + + def test_parse_string_non_string_input(self): + """parse_string can identify a non-string input""" + with self.assertRaises(ValueError): + bitmath.parse_string(12345) + + def test_parse_string_unicode(self): + """parse_string can handle a unicode string""" + self.assertEqual( + bitmath.parse_string(u"750 GiB"), + bitmath.GiB(750)) + + ###################################################################### + # Deprecated public entry point tests + + def test_parse_string_unsafe_deprecation_warning(self): + """parse_string_unsafe emits DeprecationWarning as of 2.0.0""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + bitmath.parse_string_unsafe("100 GiB") + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[0].category, DeprecationWarning)) + self.assertIn("2.0.0", str(w[0].message)) + self.assertIn("parse_string", str(w[0].message)) + + def test_parse_string_unsafe_request_NIST(self): + """parse_string_unsafe still delegates correctly with explicit system""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + + _parsed = bitmath.parse_string_unsafe("100M", system=bitmath.NIST) + self.assertEqual(_parsed, bitmath.MiB(100)) + self.assertIs(type(_parsed), bitmath.MiB) + + _parsed2 = bitmath.parse_string_unsafe("100k", system=bitmath.NIST) + self.assertEqual(_parsed2, bitmath.KiB(100)) + self.assertIs(type(_parsed2), bitmath.KiB) + + _parsed3 = bitmath.parse_string_unsafe("100", system=bitmath.NIST) + self.assertEqual(_parsed3, bitmath.Byte(100)) + self.assertIs(type(_parsed3), bitmath.Byte) + + _parsed4 = bitmath.parse_string_unsafe("100kb", system=bitmath.NIST) + self.assertEqual(_parsed4, bitmath.KiB(100)) + self.assertIs(type(_parsed4), bitmath.KiB) diff --git a/tests/test_parse.py b/tests/test_parse_unsafe.py similarity index 61% rename from tests/test_parse.py rename to tests/test_parse_unsafe.py index 5de0ed1..d29b269 100644 --- a/tests/test_parse.py +++ b/tests/test_parse_unsafe.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -26,124 +26,14 @@ """ -Test parsing strings into bitmath objects +Tests for parse_string with strict=False (the non-strict / formerly unsafe path). """ from . import TestCase import bitmath -class TestParse(TestCase): - def test_parse_b(self): - """parse_string works on bit strings""" - self.assertEqual( - bitmath.parse_string("123b"), - bitmath.Bit(123)) - - def test_parse_B(self): - """parse_string works on byte strings""" - self.assertEqual( - bitmath.parse_string("321B"), - bitmath.Byte(321)) - - def test_parse_Gb(self): - """parse_string works on gigabit strings""" - self.assertEqual( - bitmath.parse_string("456Gb"), - bitmath.Gb(456)) - - def test_parse_MiB(self): - """parse_string works on mebibyte strings""" - self.assertEqual( - bitmath.parse_string("654 MiB"), - bitmath.MiB(654)) - - ###################################################################### - # NIST 'octet' based units - def test_parse_Mio(self): - """parse_string works on mebioctet strings""" - self.assertEqual( - bitmath.parse_string("654 Mio"), - bitmath.MiB(654)) - - def test_parse_Eio(self): - """parse_string works on exbioctet strings""" - self.assertEqual( - bitmath.parse_string("654 Eio"), - bitmath.EiB(654)) - - def test_parse_Zio(self): - """parse_string works on zebioctet strings""" - self.assertEqual( - bitmath.parse_string("654 Zio"), - bitmath.ZiB(654)) - - def test_parse_Yio(self): - """parse_string works on yobioctet strings""" - self.assertEqual( - bitmath.parse_string("654 Yio"), - bitmath.YiB(654)) - - # SI 'octet' based units - def test_parse_Mo(self): - """parse_string works on megaoctet strings""" - self.assertEqual( - bitmath.parse_string("654 Mo"), - bitmath.MB(654)) - - def test_parse_Eo(self): - """parse_string works on exaoctet strings""" - self.assertEqual( - bitmath.parse_string("654 Eo"), - bitmath.EB(654)) - - def test_parse_Zo(self): - """parse_string works on zettaoctet strings""" - self.assertEqual( - bitmath.parse_string("654 Zo"), - bitmath.ZB(654)) - - def test_parse_Yo(self): - """parse_string works on yottaoctet strings""" - self.assertEqual( - bitmath.parse_string("654 Yo"), - bitmath.YB(654)) - - ###################################################################### - - def test_parse_bad_float(self): - """parse_string can identify invalid float values""" - with self.assertRaises(ValueError): - bitmath.parse_string("1.23.45 kb") - - def test_parse_bad_unit(self): - """parse_string can identify invalid prefix units""" - with self.assertRaises(ValueError): - bitmath.parse_string("1.23 GIB") - - def test_parse_bad_unit2(self): - """parse_string can identify other prefix units""" - with self.assertRaises(ValueError): - bitmath.parse_string("1.23 QB") - - def test_parse_no_unit(self): - """parse_string can identify strings without units at all""" - with self.assertRaises(ValueError): - bitmath.parse_string("12345") - - def test_parse_string_non_string_input(self): - """parse_string can identify a non-string input""" - with self.assertRaises(ValueError): - bitmath.parse_string(12345) - - def test_parse_string_unicode(self): - """parse_string can handle a unicode string""" - self.assertEqual( - bitmath.parse_string(u"750 GiB"), - bitmath.GiB(750)) - - ###################################################################### - +class TestParseUnsafe(TestCase): def test_parse_non_strict_bad_input_type(self): """parse_string strict=False can identify invalid input types""" with self.assertRaises(ValueError): @@ -324,36 +214,3 @@ def test_parse_non_strict_byte_word_forms(self): result = bitmath.parse_string(f"42 {unit}", strict=False) self.assertEqual(result, expected, msg=f"Failed for unit '{unit}'") self.assertIs(type(result), bitmath.Byte, msg=f"Wrong type for unit '{unit}'") - - def test_parse_string_unsafe_deprecation_warning(self): - """parse_string_unsafe emits DeprecationWarning as of 2.0.0""" - import warnings - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - bitmath.parse_string_unsafe("100 GiB") - self.assertEqual(len(w), 1) - self.assertTrue(issubclass(w[0].category, DeprecationWarning)) - self.assertIn("2.0.0", str(w[0].message)) - self.assertIn("parse_string", str(w[0].message)) - - def test_parse_string_unsafe_request_NIST(self): - """parse_string_unsafe still delegates correctly with explicit system""" - import warnings - with warnings.catch_warnings(record=True): - warnings.simplefilter("always") - - _parsed = bitmath.parse_string_unsafe("100M", system=bitmath.NIST) - self.assertEqual(_parsed, bitmath.MiB(100)) - self.assertIs(type(_parsed), bitmath.MiB) - - _parsed2 = bitmath.parse_string_unsafe("100k", system=bitmath.NIST) - self.assertEqual(_parsed2, bitmath.KiB(100)) - self.assertIs(type(_parsed2), bitmath.KiB) - - _parsed3 = bitmath.parse_string_unsafe("100", system=bitmath.NIST) - self.assertEqual(_parsed3, bitmath.Byte(100)) - self.assertIs(type(_parsed3), bitmath.Byte) - - _parsed4 = bitmath.parse_string_unsafe("100kb", system=bitmath.NIST) - self.assertEqual(_parsed4, bitmath.KiB(100)) - self.assertIs(type(_parsed4), bitmath.KiB) diff --git a/tests/test_properties.py b/tests/test_properties.py index ece0e21..056d021 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -73,3 +73,12 @@ def test_Zib_property(self): def test_Yib_property(self): """Yib property returns a Yib instance""" self.assertIsInstance(self.kib.Yib, bitmath.Yib) + + +class TestSystemPropertyInvalidBase(TestCase): + def test_system_property_invalid_base_raises(self): + """system property raises ValueError when _base is not 2 or 10""" + obj = bitmath.MiB(1) + obj._base = 7 + with self.assertRaises(ValueError): + _ = obj.system diff --git a/tests/test_query_capacity.py b/tests/test_query_capacity.py index a6f8b84..e01c411 100644 --- a/tests/test_query_capacity.py +++ b/tests/test_query_capacity.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2026 Tim Case +# SPDX-FileCopyrightText: 2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -30,7 +30,7 @@ import os import pathlib -from unittest import skipUnless +from unittest import mock, skipUnless import bitmath from bitmath import Bitmath, Byte, Capacity @@ -125,3 +125,19 @@ def test_query_capacity_windows_pathlike_drive_letter(self): result = bitmath.query_capacity(pathlib.PureWindowsPath("C:")) self.assertIsInstance(result, Capacity) self.assertGreater(result.total.bytes, 0) + + +class TestQueryCapacityWindowsDriveLetterMock(TestCase): + def test_query_capacity_windows_drive_letter_normalization_mock(self): + """query_capacity normalizes a bare drive letter via mocked os.name='nt'""" + mock_usage = mock.MagicMock() + mock_usage.total = 1_000_000_000_000 + mock_usage.used = 500_000_000_000 + mock_usage.free = 500_000_000_000 + + with mock.patch('bitmath.os.name', 'nt'): + with mock.patch('bitmath.shutil.disk_usage', return_value=mock_usage) as mock_du: + result = bitmath.query_capacity("c:") + + self.assertIsInstance(result, Capacity) + mock_du.assert_called_once_with("C:\\") diff --git a/tests/test_query_device_capacity.py b/tests/test_query_device_capacity.py index 240fbf3..2c631e9 100644 --- a/tests/test_query_device_capacity.py +++ b/tests/test_query_device_capacity.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2015 Tim Case +# SPDX-FileCopyrightText: 2015-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files @@ -31,7 +31,9 @@ from . import TestCase import bitmath +import ctypes as real_ctypes import os +import types from unittest import mock, skipUnless import struct from contextlib import ExitStack, contextmanager @@ -133,3 +135,63 @@ def test_query_device_capacity_unsupported_platform_fails(self): with mock.patch('bitmath.os.name', unsupported): with self.assertRaises(NotImplementedError): bitmath.query_device_capacity(device) + + +class TestQueryDeviceCapacityWindowsBody(TestCase): + """Mock-based tests for _query_device_capacity_windows body. + + Run on all platforms by injecting ctypes and msvcrt into the bitmath + namespace via mock.patch(..., create=True). + """ + + def _make_mock_ctypes(self): + mc = types.SimpleNamespace( + Structure=real_ctypes.Structure, + c_longlong=real_ctypes.c_longlong, + c_uint=real_ctypes.c_uint, + c_ulong=real_ctypes.c_ulong, + c_byte=real_ctypes.c_byte, + byref=real_ctypes.byref, + sizeof=real_ctypes.sizeof, + wintypes=types.SimpleNamespace(DWORD=real_ctypes.c_ulong), + windll=mock.MagicMock(), + ) + mc.windll.kernel32.DeviceIoControl.return_value = 1 + return mc + + def _make_mock_msvcrt(self): + return types.SimpleNamespace(get_osfhandle=mock.Mock(return_value=999)) + + def _make_windows_device(self): + fd = mock.MagicMock() + fd.name = r'\\.\PhysicalDrive0' + fd.fileno.return_value = 4 + return fd + + def test_windows_body_success(self): + """_query_device_capacity_windows succeeds via mocked ctypes and msvcrt""" + mock_ctypes = self._make_mock_ctypes() + mock_msvcrt = self._make_mock_msvcrt() + device_fd = self._make_windows_device() + + with mock.patch('bitmath.ctypes', mock_ctypes, create=True): + with mock.patch('bitmath.msvcrt', mock_msvcrt, create=True): + result = bitmath._query_device_capacity_windows(device_fd) + + # DiskSize is 0 by default — mock DeviceIoControl does not fill the struct + self.assertEqual(result, 0) + mock_msvcrt.get_osfhandle.assert_called_once_with(4) + mock_ctypes.windll.kernel32.DeviceIoControl.assert_called_once() + + def test_windows_body_ioctl_failure_raises_oserror(self): + """_query_device_capacity_windows raises OSError when DeviceIoControl fails""" + mock_ctypes = self._make_mock_ctypes() + mock_ctypes.windll.kernel32.DeviceIoControl.return_value = 0 + mock_ctypes.windll.kernel32.GetLastError.return_value = 5 + mock_msvcrt = self._make_mock_msvcrt() + device_fd = self._make_windows_device() + + with mock.patch('bitmath.ctypes', mock_ctypes, create=True): + with mock.patch('bitmath.msvcrt', mock_msvcrt, create=True): + with self.assertRaises(OSError): + bitmath._query_device_capacity_windows(device_fd) diff --git a/tests/test_representation.py b/tests/test_representation.py index 86a4dcb..527e4fd 100644 --- a/tests/test_representation.py +++ b/tests/test_representation.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_rich_comparison.py b/tests/test_rich_comparison.py index c52818d..356a97b 100644 --- a/tests/test_rich_comparison.py +++ b/tests/test_rich_comparison.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_rounding.py b/tests/test_rounding.py index 73eff23..b6a4ac2 100644 --- a/tests/test_rounding.py +++ b/tests/test_rounding.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_sorting.py b/tests/test_sorting.py index 5bcf08b..1ccd5ed 100644 --- a/tests/test_sorting.py +++ b/tests/test_sorting.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_sum.py b/tests/test_sum.py index 0631331..0de4130 100644 --- a/tests/test_sum.py +++ b/tests/test_sum.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_to_Type_conversion.py b/tests/test_to_Type_conversion.py index 818fd57..99aca73 100644 --- a/tests/test_to_Type_conversion.py +++ b/tests/test_to_Type_conversion.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_to_built_in_conversion.py b/tests/test_to_built_in_conversion.py index 4450b63..07e4af1 100644 --- a/tests/test_to_built_in_conversion.py +++ b/tests/test_to_built_in_conversion.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files diff --git a/tests/test_unique_testcase_names.sh b/tests/test_unique_testcase_names.sh index b40891d..634fc27 100755 --- a/tests/test_unique_testcase_names.sh +++ b/tests/test_unique_testcase_names.sh @@ -1,6 +1,6 @@ #!/bin/bash -grep class tests/*.py | awk '{ +grep "^class" tests/*.py | awk '{ arr[$NF]++ } END { diff --git a/tests/test_utils.py b/tests/test_utils.py index 9882cb4..950617d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # The MIT License (MIT) # -# Copyright © 2014 Tim Case +# SPDX-FileCopyrightText: 2014-2026 Tim Case # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files