From b6b27ec6f619908aff35eea4e60fdecd3b8e7e47 Mon Sep 17 00:00:00 2001 From: WheheoHu Date: Tue, 12 May 2026 20:26:50 +0800 Subject: [PATCH 1/5] Refactor code structure for improved readability and maintainability --- dftt_timecode/core/dftt_timecode.py | 108 +++----- pyproject.toml | 100 +++---- setup.py | 10 - test/test_dftt_timecode.py | 10 +- uv.lock | 410 ++++++++++++++++++---------- 5 files changed, 359 insertions(+), 279 deletions(-) delete mode 100644 setup.py diff --git a/dftt_timecode/core/dftt_timecode.py b/dftt_timecode/core/dftt_timecode.py index cbf2bc0..ab96446 100644 --- a/dftt_timecode/core/dftt_timecode.py +++ b/dftt_timecode/core/dftt_timecode.py @@ -8,6 +8,7 @@ from fractions import Fraction from functools import singledispatchmethod from math import ceil, floor +import re from typing import Literal, TypeAlias, Union from dftt_timecode.error import ( @@ -141,7 +142,7 @@ class DfttTimecode: - :mod:`dftt_timecode.pattern`: Regex patterns for format validation - :mod:`dftt_timecode.error`: Custom exception classes """ - __type = 'time' + __type: TimecodeType = 'time' __fps = 24.0 # 帧率 __nominal_fps = 24 # 名义帧率(无小数,进一法取整) __drop_frame = False # 是否丢帧Dropframe(True为丢帧,False为不丢帧) @@ -157,7 +158,7 @@ def __new__(cls, timecode_value=0, timecode_type='auto', fps=24.0, drop_frame=Fa def __validate_drop_frame(self, drop_frame: bool, fps: float) -> bool: if round(fps, 2) % 29.97 == 0: # FPS为29.97以及倍数时候,尊重drop_frame参数(for 29.97/59.94/119.88 NDF) - return False if not drop_frame else True + return drop_frame else: return round(fps, 2) % 23.98 == 0 @@ -184,27 +185,24 @@ def __detect_timecode_type(self,timecode_value)->TimecodeType: elif TIME_REGEX.match(timecode_value): return 'time' else: - # No pattern matched - raise error instead of returning None - logger.error(f'Cannot detect timecode type for value [{timecode_value}]. No matching pattern found.') raise DFTTTimecodeTypeError(f'Cannot detect timecode type for value [{timecode_value}]. No matching pattern found.') def __apply_strict(self) -> None: """Apply 24h wraparound if strict mode enabled""" if self.__strict: self.__precise_time %= 86400 - + + @staticmethod + def _ensure_match(pattern:re.Pattern[str], timecode_value:str, timecode_type:str) -> re.Match[str]: + if not (match_result:=pattern.match(timecode_value)): + raise DFTTTimecodeTypeError(f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') + return match_result + def __init_smpte(self, timecode_value: str,minus_flag:bool): - if not SMPTE_REGEX.match(timecode_value): # 判断输入是否符合 - logger.error( - f'Timecode type [smpte] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - temp_timecode_list = [int(x) if x else 0 for x in SMPTE_REGEX.match( - timecode_value).groups()] # 正则取值 - hh,mm,ss,ff = temp_timecode_list + match = self._ensure_match(SMPTE_REGEX, timecode_value, 'smpte') + hh,mm,ss,ff = map(int,match.groups(default='0')) # 将捕获的时分秒帧转换为整数,缺失部分默认为0 if ff > self.__nominal_fps - 1: # 判断输入帧号在当前帧率下是否合法 - logger.error( - f'This timecode: [{timecode_value}] is illegal under given params, check your input!') - raise DFTTTimecodeValueError + raise DFTTTimecodeValueError(f'This timecode: [{timecode_value}] is illegal under given params, check your input!') if not self.__drop_frame: # 时码丢帧处理逻辑 frame_index = ff + self.__nominal_fps * \ @@ -213,9 +211,8 @@ def __init_smpte(self, timecode_value: str,minus_flag:bool): drop_per_min = self.__nominal_fps / 30 * 2 # 检查是否有DF下不合法的帧号 if mm % 10 != 0 and ss == 0 and ff in (0, drop_per_min - 1): - logger.error( + raise DFTTTimecodeValueError( f'This timecode: [{timecode_value}] is illegal under given params, check your input!') - raise DFTTTimecodeValueError else: total_minutes = 60 * hh + mm frame_index = (hh * 3600 + mm * 60 + ss) * self.__nominal_fps + ff - ( @@ -231,16 +228,10 @@ def __init_smpte(self, timecode_value: str,minus_flag:bool): self.__precise_time = Fraction(frame_index) / Fraction(self.__fps) # 时间戳=帧号/帧率 def __init_srt(self, timecode_value: str,minus_flag:bool): - if not SRT_REGEX.match(timecode_value): # 判断输入是否符合SRT类型 - logger.error( - f'Timecode type [srt] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - - temp_timecode_list = [ - int(x) if x else 0 for x in SRT_REGEX.match(timecode_value).groups()] + match = self._ensure_match(SRT_REGEX, timecode_value, 'srt') + hh, mm, ss, sub_sec = map(int, match.groups(default='0')) # 由于SRT格式本身不存在帧率,将为SRT赋予默认帧率和丢帧状态 logger.info(f'SRT timecode framerate {self.__fps}, DF={self.__drop_frame} assigned') - hh,mm,ss,sub_sec = temp_timecode_list self.__precise_time = Fraction(hh * 3600 + mm * 60 + ss + sub_sec / 1000) if minus_flag: @@ -250,15 +241,10 @@ def __init_srt(self, timecode_value: str,minus_flag:bool): def __init_dlp(self, timecode_value: str, minus_flag: bool): - if not DLP_REGEX.match(timecode_value): - logger.error( - f'Timecode type [dlp] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - temp_timecode_list = [ - int(x) if x else 0 for x in DLP_REGEX.match(timecode_value).groups()] + match = self._ensure_match(DLP_REGEX, timecode_value, 'dlp') + hh, mm, ss, sub_sec = map(int, match.groups(default='0')) # 由于DLP不存在帧率,将为DLP赋予默认帧率和丢帧状态 logger.info(f'DLP timecode framerate {self.__fps}, DF={self.__drop_frame} assigned') - hh, mm, ss, sub_sec = temp_timecode_list # dlp每秒共250个子帧 即4ms一个 # 详见https://interop-docs.cinepedia.com/Reference_Documents/CineCanvas(tm)_RevC.pdf 第17页 “TimeIn”部分 @@ -269,12 +255,8 @@ def __init_dlp(self, timecode_value: str, minus_flag: bool): def __init_ffmpeg(self, timecode_value: str,minus_flag:bool): - if not FFMPEG_REGEX.match(timecode_value): - logger.error(f'Timecode type [ffmpeg] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - temp_timecode_list = [ - int(x) if x else 0 for x in FFMPEG_REGEX.match(timecode_value).groups()] - hh,mm,ss,sub_sec = temp_timecode_list + match = self._ensure_match(FFMPEG_REGEX, timecode_value, 'ffmpeg') + hh, mm, ss, sub_sec = map(int, match.groups(default='0')) self.__precise_time = Fraction(hh * 3600 + mm * 60 + ss + float(f'0.{sub_sec}')) if minus_flag: self.__precise_time = -self.__precise_time @@ -282,22 +264,17 @@ def __init_ffmpeg(self, timecode_value: str,minus_flag:bool): self.__apply_strict() def __init_fcpx(self, timecode_value: str,minus_flag:bool): - if not FCPX_REGEX.match(timecode_value): - logger.error(f'Timecode type [fcpx] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - temp_timecode_list = [ - int(x) if x else 0 for x in FCPX_REGEX.match(timecode_value).groups()] - self.__precise_time = Fraction(temp_timecode_list[0], temp_timecode_list[1]) + match = self._ensure_match(FCPX_REGEX, timecode_value, 'fcpx') + numerator, denominator = map(int, match.groups(default='0')) + self.__precise_time = Fraction(numerator, denominator) if minus_flag: self.__precise_time = -self.__precise_time self.__apply_strict() def __init_frame(self, timecode_value: str,minus_flag:bool): - if not FRAME_REGEX.match(timecode_value): - logger.error(f'Timecode type [frame] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - temp_frame_index = int(FRAME_REGEX.match(timecode_value).group(1)) + match = self._ensure_match(FRAME_REGEX, timecode_value, 'frame') + temp_frame_index = int(match.group(1)) if self.__strict: # 严格模式,对于丢帧时码而言 用实际FPS运算,对于不丢帧时码而言,使用名义FPS运算 temp_frame_index = temp_frame_index % ( self.__fps * 86400) if self.__drop_frame else temp_frame_index % ( @@ -307,16 +284,13 @@ def __init_frame(self, timecode_value: str,minus_flag:bool): self.__precise_time = Fraction(temp_frame_index) / Fraction(self.__fps) # 转换为内部精准时间戳 def __init_time(self, timecode_value: str,minus_flag:bool): - if not TIME_REGEX.match(timecode_value): - logger.error(f'Timecode type [time] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError - temp_timecode_value = TIME_REGEX.match(timecode_value).group(1) - self.__precise_time = Fraction(temp_timecode_value) # 内部时间戳直接等于输入值 + match = self._ensure_match(TIME_REGEX, timecode_value, 'time') + self.__precise_time = Fraction(match.group(1)) # 内部时间戳直接等于输入值 self.__apply_strict() - def __init_common(self, timecode_type,fps,drop_frame,strict): - self.__type = timecode_type + def __init_common(self, timecode_type: TimecodeType, fps: float, drop_frame: bool, strict: bool): + self.__type: TimecodeType = timecode_type self.__fps = fps self.__nominal_fps = ceil(fps) self.__drop_frame = self.__validate_drop_frame(drop_frame, fps) @@ -327,7 +301,7 @@ def __init__(self, timecode_value, timecode_type, fps, drop_frame, strict): # raise TypeError(f"Unsupported timecode value type: {type(timecode_value)}") @__init__.register # 若传入的TC值为字符串,则调用此函数 - def _(self, timecode_value: str, timecode_type:TimecodeType='auto', fps=24.0, drop_frame=None, strict=True): + def _(self, timecode_value: str, timecode_type:TimecodeType='auto', fps=24.0, drop_frame=False, strict=True): # if timecode_value[0] == '-': # 判断首位是否为负,并为flag赋值 # minus_flag = True # else: @@ -341,8 +315,8 @@ def _(self, timecode_value: str, timecode_type:TimecodeType='auto', fps=24.0, dr timecode_type= timecode_type if timecode_type != 'auto' else self.__detect_timecode_type(timecode_value) - self.__type = timecode_type - + self.__type: TimecodeType = timecode_type + timecode_type_handler_map={ 'smpte':self.__init_smpte, 'srt':self.__init_srt, @@ -388,7 +362,7 @@ def _(self, timecode_value: int, timecode_type='frame', fps=24.0, drop_frame=Fal elif timecode_type == 'time': self.__init_common(timecode_type,fps,drop_frame,strict) - self.__precise_time = timecode_value # 内部时间戳直接等于输入值 + self.__precise_time = Fraction(timecode_value) # 内部时间戳直接等于输入值 self.__apply_strict() else: logger.error( @@ -439,11 +413,11 @@ def _(self, timecode_value: list, timecode_type='time', fps=24.0, drop_frame=Fal logger.debug(instance_success_log) @property - def type(self) -> str: + def type(self) -> TimecodeType: """Get the current timecode format type. Returns: - str: The timecode format type (e.g., 'smpte', 'srt', 'ffmpeg') + TimecodeType: The timecode format type (e.g., 'smpte', 'srt', 'ffmpeg') Example: >>> tc = DfttTimecode('01:00:00:00', fps=24) @@ -654,7 +628,7 @@ def _convert_to_output_smpte(self, output_part=0) -> str: # 计算framecount用于输出smpte时码个部分值 if not self.__drop_frame: # 不丢帧 # 对于不丢帧时码而言 framecount 为帧计数 - _nominal_framecount = frame_index + _nominal_framecount = int(frame_index) else: # 丢帧 drop_per_min = self.__nominal_fps / 30 * 2 # 提前计算每分钟丢帧数量 简化后续计算 df_framecount_10min = self.__nominal_fps * 600 - 9 * drop_per_min @@ -664,7 +638,7 @@ def _convert_to_output_smpte(self, output_part=0) -> str: # 剩余小于十分钟部分计算丢了多少帧,补偿 ((m - drop_per_min) // (self.__nominal_fps * 60 - drop_per_min)) if m > 2 else 0) - _nominal_framecount = drop_frame_frame_number + _nominal_framecount = int(drop_frame_frame_number) def _convert_framecount_to_smpte_parts(frame_count: int, fps: int) -> tuple: hour, r_1 = divmod(frame_count, 60*60*fps) @@ -877,8 +851,8 @@ def set_fps(self, dest_fps: float, rounding: bool = True) -> 'DfttTimecode': self.__fps = dest_fps self.__nominal_fps = ceil(self.__fps) if rounding: - self.__precise_time = round( - self.__precise_time * self.__fps) / self.__fps + self.__precise_time = Fraction(round( + self.__precise_time * self.__fps) / self.__fps) else: pass return self @@ -1297,7 +1271,7 @@ def __rtruediv__(self, other): logger.error(f'Undefined division with [{type(other)}].') raise DFTTTimecodeOperatorError - def __eq__(self, other: Union['DfttTimecode', int, float, Fraction]) -> bool: + def __eq__(self, other: object) -> bool: """Check equality with another timecode or numeric value. Args: @@ -1340,7 +1314,7 @@ def __eq__(self, other: Union['DfttTimecode', int, float, Fraction]) -> bool: logger.error(f'CANNOT compare with data type [{type(other)}].') raise DFTTTimecodeTypeError - def __ne__(self, other: Union['DfttTimecode', int, float, Fraction]) -> bool: + def __ne__(self, other: object) -> bool: """Check inequality with another timecode or numeric value. Args: diff --git a/pyproject.toml b/pyproject.toml index 330eab8..6d1cfa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,48 +1,52 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "dftt-timecode" -version = "1.0.0b3" -description = "Timecode library for film and TV industry, supports HFR and a bunch of cool features" -readme = "README.md" -requires-python = ">=3.11" -license = "LGPL-2.1" -authors = [ - {name = "You Ziyuan", email = "hikaridragon0216@gmail.com"}, - {name = "Wheheo Hu", email = "wheheohu@outlook.com"} -] -classifiers = [ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Development Status :: 4 - Beta", - "Natural Language :: Chinese (Simplified)", - "Operating System :: OS Independent", -] -keywords = ["timecode", "video", "film", "television", "smpte", "hfr"] - -dependencies = [] - -[project.urls] -Homepage = "https://github.com/OwenYou/dftt_timecode" -Documentation = "https://owenyou.github.io/dftt_timecode/" -Repository = "https://github.com/OwenYou/dftt_timecode" -Issues = "https://github.com/OwenYou/dftt_timecode/issues" - -[tool.setuptools.packages.find] -include = ["dftt_timecode*"] - -[tool.pytest.ini_options] -addopts = "-v -s" -testpaths = ["test"] - -[dependency-groups] -dev = [ - "pytest>=8.3.4", - "sphinx>=7.0.0", - "pydata-sphinx-theme>=0.15.0", - "myst-parser>=4.0.0", - "sphinx-intl>=2.0.0", -] +[build-system] +requires = ["uv_build>=0.11,<0.13"] +build-backend = "uv_build" + +[project] +name = "dftt-timecode" +version = "1.0.0b3" +description = "Timecode library for film and TV industry, supports HFR and a bunch of cool features" +readme = "README.md" +requires-python = ">=3.11" +license = "LGPL-2.1-only" +authors = [ + {name = "You Ziyuan", email = "hikaridragon0216@gmail.com"}, + {name = "Wheheo Hu", email = "wheheohu@outlook.com"} +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Development Status :: 4 - Beta", + "Natural Language :: Chinese (Simplified)", + "Operating System :: OS Independent", +] +keywords = ["timecode", "video", "film", "television", "smpte", "hfr"] + +dependencies = [] + +[project.urls] +Homepage = "https://github.com/OwenYou/dftt_timecode" +Documentation = "https://owenyou.github.io/dftt_timecode/" +Repository = "https://github.com/OwenYou/dftt_timecode" +Issues = "https://github.com/OwenYou/dftt_timecode/issues" + +[tool.uv.build-backend] +module-root = "" +source-exclude = [".DS_Store", "**/.DS_Store"] +wheel-exclude = [".DS_Store", "**/.DS_Store"] + +[tool.pytest.ini_options] +addopts = "-v -s" +testpaths = ["test"] + +[dependency-groups] +dev = [ + "pytest>=8.3.4", + "sphinx>=7.0.0", + "pydata-sphinx-theme>=0.15.0", + "myst-parser>=4.0.0", + "sphinx-intl>=2.0.0", + "ruff>=0.15.12", + "ty>=0.0.35", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index 5bcb685..0000000 --- a/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Setup script for dftt_timecode. - -This file is maintained for backward compatibility. -The main configuration is in pyproject.toml. -""" -import setuptools - -if __name__ == "__main__": - setuptools.setup() diff --git a/test/test_dftt_timecode.py b/test/test_dftt_timecode.py index 9752833..858f58d 100644 --- a/test/test_dftt_timecode.py +++ b/test/test_dftt_timecode.py @@ -716,7 +716,7 @@ def test_mul_xfail(): tc_1 = TC("00:00:00:23", "auto", 24, False, True) tc_2 = TC("00:11:45:14", "auto", 24, False, True) with pytest.raises(DFTTTimecodeOperatorError): - tc_1 * tc_2 + tc_1 * tc_2 # ty: ignore[unsupported-operator] @pytest.fixture( @@ -1192,9 +1192,9 @@ def test_move_frame_invalid_input(): """Test move_frame raises error with non-integer input.""" tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) with pytest.raises(DFTTTimecodeOperatorError): - tc.move_frame(10.5) # Float should raise error + tc.move_frame(10.5) # ty: ignore[invalid-argument-type] # Float should raise error with pytest.raises(DFTTTimecodeOperatorError): - tc.move_frame("100") # String should raise error + tc.move_frame("100") # ty: ignore[invalid-argument-type] # String should raise error # Tests for move_time method @@ -1279,9 +1279,9 @@ def test_move_time_invalid_input(): """Test move_time raises error with invalid input types.""" tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) with pytest.raises(DFTTTimecodeOperatorError): - tc.move_time("10.5") # String should raise error + tc.move_time("10.5") # ty: ignore[invalid-argument-type] # String should raise error with pytest.raises(DFTTTimecodeOperatorError): - tc.move_time([10.5]) # List should raise error + tc.move_time([10.5]) # ty: ignore[invalid-argument-type] # List should raise error # Combined test for move_frame and move_time equivalence diff --git a/uv.lock b/uv.lock index 0af2b8a..2e493ee 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] [[package]] name = "accessible-pygments" @@ -25,118 +29,134 @@ wheels = [ [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "beautifulsoup4" -version = "4.14.2" +version = "4.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.0" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -158,8 +178,11 @@ dev = [ { name = "myst-parser" }, { name = "pydata-sphinx-theme" }, { name = "pytest" }, - { name = "sphinx" }, + { name = "ruff" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-intl" }, + { name = "ty" }, ] [package.metadata] @@ -169,35 +192,37 @@ dev = [ { name = "myst-parser", specifier = ">=4.0.0" }, { name = "pydata-sphinx-theme", specifier = ">=0.15.0" }, { name = "pytest", specifier = ">=8.3.4" }, + { name = "ruff", specifier = ">=0.15.12" }, { name = "sphinx", specifier = ">=7.0.0" }, { name = "sphinx-intl", specifier = ">=2.0.0" }, + { name = "ty", specifier = ">=0.0.35" }, ] [[package]] name = "docutils" -version = "0.21.2" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, ] [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] @@ -223,14 +248,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -309,14 +334,14 @@ wheels = [ [[package]] name = "mdit-py-plugins" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/3d/e0e8d9d1cee04f758120915e2b2a3a07eb41f8cf4654b4734788a522bcd1/mdit_py_plugins-0.6.0.tar.gz", hash = "sha256:2436f14a7295837ac9228a36feeabda867c4abc488c8d019ad5c0bda88eee040", size = 56025, upload-time = "2026-05-07T12:20:42.295Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, + { url = "https://files.pythonhosted.org/packages/71/d6/48f5b9e44e2e760855d7b489b1317cd7620e82dcb73197961e5cc1391348/mdit_py_plugins-0.6.0-py3-none-any.whl", hash = "sha256:f7e7a25d8b616fee99cb1e330da73451d11a8061baf39bb9663ab9ce0e005b90", size = 66655, upload-time = "2026-05-07T12:20:41.226Z" }, ] [[package]] @@ -330,7 +355,7 @@ wheels = [ [[package]] name = "myst-parser" -version = "4.0.1" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -338,20 +363,21 @@ dependencies = [ { name = "markdown-it-py" }, { name = "mdit-py-plugins" }, { name = "pyyaml" }, - { name = "sphinx" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -365,7 +391,7 @@ wheels = [ [[package]] name = "pydata-sphinx-theme" -version = "0.16.1" +version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, @@ -373,26 +399,27 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "docutils" }, { name = "pygments" }, - { name = "sphinx" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f7/c74c7100a7f4c0f77b5dcacb7dfdb8fee774fb70e487dd97acba2b930774/pydata_sphinx_theme-0.17.1.tar.gz", hash = "sha256:2cfc1d926c753c77039b7ee53f0ccebcbee5e81f0db61432b01cbb10ad7fd0af", size = 4991415, upload-time = "2026-04-21T13:00:34.263Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z" }, + { url = "https://files.pythonhosted.org/packages/e2/bc/2cb8c78300ce1ace4eeac3b3522218cea2c2053bfa6b4e32cc972a477f9a/pydata_sphinx_theme-0.17.1-py3-none-any.whl", hash = "sha256:320b022d7808bdf5920d9a28e573f27aace9b23e1af6ca103eecc752411df492", size = 6823346, upload-time = "2026-04-21T13:00:31.978Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -401,9 +428,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -463,7 +490,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -471,18 +498,43 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] -name = "roman-numerals-py" -version = "3.1.0" +name = "ruff" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -496,39 +548,73 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "sphinx" -version = "8.2.3" +version = "9.0.4" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version < '3.12'" }, + { name = "babel", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.12'" }, + { name = "imagesize", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, + { name = "roman-numerals", marker = "python_full_version < '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] @@ -538,7 +624,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, { name = "click" }, - { name = "sphinx" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/21/eb12016ecb0b52861762b0d227dff75622988f238776a5ee4c75bade507e/sphinx_intl-2.3.2.tar.gz", hash = "sha256:04b0d8ea04d111a7ba278b17b7b3fe9625c58b6f8ffb78bb8a1dd1288d88c1c7", size = 27921, upload-time = "2025-08-02T04:53:01.891Z" } wheels = [ @@ -599,6 +686,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "ty" +version = "0.0.35" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/53/440e7b1212c4b0abbd4adb7aed93f4971aa1f8dca386ac5515930afa9172/ty-0.0.35.tar.gz", hash = "sha256:8375c240ab38138a19db07996c9808fb7a92047c1492e1ce587c2ef5112ad3a9", size = 5629237, upload-time = "2026-05-10T18:25:17.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/84/19662ee881675815b7fafff940a365be1985730465afd9b75cb2edd5f8b3/ty-0.0.35-py3-none-linux_armv6l.whl", hash = "sha256:85ae1e59b9fb0b40e9d84fe61b29653c5f2f5e78b487ece371a7a38c20c781cf", size = 11198741, upload-time = "2026-05-10T18:24:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b", size = 10948304, upload-time = "2026-05-10T18:24:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc", size = 10407413, upload-time = "2026-05-10T18:24:37.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/23/fda6fae8a81ce0cb5f24cdfe63260e110c7af8844e31fa07d1e6e8ef0232/ty-0.0.35-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7afbcfc61904b7e82e7fe1a1db832a40d8f01e69dee1775f6594e552980536c", size = 10932614, upload-time = "2026-05-10T18:24:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/72/3d/b98d8d4aa1a5ed6daaf15864e838f605ca7b1e8b93b7e17b96ed4bc4dfed/ty-0.0.35-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b61498cc3e4178031c079951257fbdb209a891b4feb10ad6c40f615a51846f41", size = 10962982, upload-time = "2026-05-10T18:24:44.88Z" }, + { url = "https://files.pythonhosted.org/packages/18/c4/2881aad71bf6fb2f8df17fc8e4bc89e904e54490a3ee747b5ef73f98ac85/ty-0.0.35-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573b1eacda349fc8dba0d767b41631c3a6f66412363127c5bf2b1b40a1d898d2", size = 11476274, upload-time = "2026-05-10T18:24:42.4Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/7717650adaeaddd23eea70470e2c26d3f0b9b18fdc7f26ec9552d6001f17/ty-0.0.35-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7209746158d6393c1040aa64b3ca29622e212ea7d8bae22ba50dbcbb4f96f0a", size = 12012027, upload-time = "2026-05-10T18:25:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/22/c9/1a16cb4aab6f4707d8f550772e91abc26d1c8870f19b5e2453ad10bb8209/ty-0.0.35-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4466a1470aa4418d49a9aa45d9da7de42033addd0a2837c5b2b0eb71d3c2bcd3", size = 11648894, upload-time = "2026-05-10T18:25:12.44Z" }, + { url = "https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b", size = 11560482, upload-time = "2026-05-10T18:25:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/a5fb11227d5cc4ac3f29a115d8c8bc817578e8ef6907d1e4c914ddbf45ee/ty-0.0.35-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:34b219250736c989b2670a03782c61315f523f3a2be37f1f90b1207e2212c188", size = 11718495, upload-time = "2026-05-10T18:24:54.12Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cb/e92e4317388b6d1fd821a46941b448a8a1ff0bf13e22147c5167d8fa1b00/ty-0.0.35-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88e2ac497decc0940ef1a07571dee8a746112a93a09cdc7f8bca0099752e2e05", size = 10900815, upload-time = "2026-05-10T18:25:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4f/03bd87388a92567f262f35ac64e10d2be047d258f2dfcf1405f500fa2b90/ty-0.0.35-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:02cae51b53e6ec17d5d827ff1a3a76fd119705b56a92156e04399eda6e911596", size = 10998051, upload-time = "2026-05-10T18:25:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/b4/60/6edbc375ee6073973200096168f644e1081e5e55a7d42596826465b275de/ty-0.0.35-py3-none-musllinux_1_2_i686.whl", hash = "sha256:11871d730c9400d899ac0b9f3d660ed2e7e433377c8725549f8250a36a7f2620", size = 11148910, upload-time = "2026-05-10T18:24:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b1/a845d2066ed521c477450f436d4bd353d107e7c02dd6536a485944aaf892/ty-0.0.35-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ad0a2f0530d0933dcc99ad36ac556c63e384ea72ab9a18d23ad2e2c9fd61c73", size = 11671005, upload-time = "2026-05-10T18:24:56.223Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/1d5912a54fb66b2f95ac828ae61d422ef5afeae1263e4d231e40796c229f/ty-0.0.35-py3-none-win32.whl", hash = "sha256:0e25d63ec4ab116e7f6757e44d16ca9216bca679d19ecc36d119cf80faada61a", size = 10481096, upload-time = "2026-05-10T18:24:39.976Z" }, + { url = "https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl", hash = "sha256:6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5", size = 11555691, upload-time = "2026-05-10T18:25:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fb/59325221bce52f6e833d6865ce8360ef7d5e1e21151b38df6dc77c4327a7/ty-0.0.35-py3-none-win_arm64.whl", hash = "sha256:619c52c0fb2aa21961a848a1995135ad3b6d0a9aa54da0194e60f679cc200e13", size = 10925457, upload-time = "2026-05-10T18:25:10.352Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -610,9 +722,9 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] From ed527645c421ee8df4cc08ab3520a0d2d0c04a6a Mon Sep 17 00:00:00 2001 From: WheheoHu Date: Tue, 12 May 2026 20:34:32 +0800 Subject: [PATCH 2/5] Refactor logging setup: remove logging_config module and integrate logging directly into core modules --- dftt_timecode/__init__.py | 6 +- dftt_timecode/core/dftt_timecode.py | 5 +- dftt_timecode/core/dftt_timerange.py | 5 +- dftt_timecode/logging_config.py | 172 ---------------- test/conftest.py | 12 ++ test/test_logging_config.py | 285 --------------------------- 6 files changed, 19 insertions(+), 466 deletions(-) delete mode 100644 dftt_timecode/logging_config.py create mode 100644 test/conftest.py delete mode 100644 test/test_logging_config.py diff --git a/dftt_timecode/__init__.py b/dftt_timecode/__init__.py index 8ad7a94..8a46f6f 100644 --- a/dftt_timecode/__init__.py +++ b/dftt_timecode/__init__.py @@ -25,11 +25,13 @@ 01:00:04:04 """ +import logging from fractions import Fraction from typing import Optional from dftt_timecode.core.dftt_timecode import DfttTimecode, TimecodeType from dftt_timecode.core.dftt_timerange import DfttTimeRange -from dftt_timecode.logging_config import configure_logging, get_logger + +logging.getLogger(__name__).addHandler(logging.NullHandler()) # Read version from package metadata (populated from pyproject.toml) try: @@ -184,6 +186,4 @@ def dtr( "timerange", "dtc", "dtr", - "configure_logging", - "get_logger", ] diff --git a/dftt_timecode/core/dftt_timecode.py b/dftt_timecode/core/dftt_timecode.py index ab96446..55696c5 100644 --- a/dftt_timecode/core/dftt_timecode.py +++ b/dftt_timecode/core/dftt_timecode.py @@ -5,6 +5,7 @@ timecode functionality for film and television production workflows. """ +import logging from fractions import Fraction from functools import singledispatchmethod from math import ceil, floor @@ -17,7 +18,6 @@ DFTTTimecodeTypeError, DFTTTimecodeValueError, ) -from dftt_timecode.logging_config import get_logger from dftt_timecode.pattern import ( DLP_REGEX, FCPX_REGEX, @@ -30,8 +30,7 @@ TIME_REGEX, ) -# Set up logger with automatic level detection based on git branch -logger = get_logger(__name__) +logger = logging.getLogger(__name__) TimecodeType: TypeAlias = Literal['smpte', 'srt', 'dlp', 'ffmpeg', 'fcpx', 'frame', 'time', 'auto'] """Type alias for supported timecode format types. diff --git a/dftt_timecode/core/dftt_timerange.py b/dftt_timecode/core/dftt_timerange.py index 9f75f0a..56e4dd7 100644 --- a/dftt_timecode/core/dftt_timerange.py +++ b/dftt_timecode/core/dftt_timerange.py @@ -5,6 +5,7 @@ supporting operations like offset, extend, intersection, union, and iteration. """ +import logging from fractions import Fraction from typing import Optional, List, Iterator, Union @@ -16,10 +17,8 @@ DFTTTimeRangeTypeError, DFTTTimeRangeValueError, ) -from dftt_timecode.logging_config import get_logger -# Set up logger with automatic level detection based on git branch -logger = get_logger(__name__) +logger = logging.getLogger(__name__) class DfttTimeRange: diff --git a/dftt_timecode/logging_config.py b/dftt_timecode/logging_config.py deleted file mode 100644 index 1172bc8..0000000 --- a/dftt_timecode/logging_config.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Logging configuration for DFTT Timecode library. - -Automatically adjusts log level based on development context: -- **Installed packages** (from PyPI/build): INFO level (production default) -- **Main branch**: INFO level (production-ready code after PR) -- **Development branches** (dev, feature/*): DEBUG level (detailed tracing) - -The logging level can be overridden with the DFTT_LOG_LEVEL environment variable. - -Example: - # Use default log level (INFO for installed packages, branch-dependent for development) - from dftt_timecode import DfttTimecode - tc = DfttTimecode('01:00:00:00', fps=24) - - # Override log level via environment variable - import os - os.environ['DFTT_LOG_LEVEL'] = 'WARNING' - - # Or configure programmatically - import logging - from dftt_timecode import configure_logging - configure_logging(logging.DEBUG) -""" - -import logging -import os -import subprocess -from typing import Optional - - -def _get_git_branch() -> Optional[str]: - """ - Get the current git branch name. - - Returns: - str: The current branch name, or None if not in a git repo or error occurs. - """ - try: - result = subprocess.run( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], - capture_output=True, - text=True, - timeout=2, - check=False - ) - if result.returncode == 0: - return result.stdout.strip() - except (subprocess.TimeoutExpired, FileNotFoundError, Exception): - pass - return None - - -def _determine_log_level() -> int: - """ - Determine the appropriate log level based on environment and git branch. - - Priority: - 1. DFTT_LOG_LEVEL environment variable (if set) - 2. DEBUG for development branches (dev, feature/*, etc.) - 3. INFO for main branch OR when git info unavailable (built packages) - - The default for built/installed packages is INFO to avoid verbose output - in production environments. - - Returns: - int: logging level constant (logging.DEBUG, logging.INFO, etc.) - """ - # Check environment variable first (highest priority) - env_level = os.environ.get('DFTT_LOG_LEVEL', '').upper() - if env_level: - level_mapping = { - 'DEBUG': logging.DEBUG, - 'INFO': logging.INFO, - 'WARNING': logging.WARNING, - 'ERROR': logging.ERROR, - 'CRITICAL': logging.CRITICAL, - } - if env_level in level_mapping: - return level_mapping[env_level] - - # Check git branch - branch = _get_git_branch() - - if branch is None: - # No git info available (e.g., installed package from PyPI) - # Default to INFO for production use - return logging.INFO - elif branch == 'main': - # Main branch: production-ready code after PR - return logging.INFO - else: - # Development/feature branches: verbose debugging - return logging.DEBUG - - -def get_logger(name: str) -> logging.Logger: - """ - Get a configured logger for the specified module. - - The logger is configured with: - - Appropriate log level based on git branch (INFO for main, DEBUG for others) - - Formatted output with timestamp, level, file, line number, function name, and message - - Stream handler for console output - - Args: - name: Logger name (typically __name__ from the calling module) - - Returns: - logging.Logger: Configured logger instance - - Example: - >>> from dftt_timecode.logging_config import get_logger - >>> logger = get_logger(__name__) - >>> logger.debug("This is a debug message") - >>> logger.info("This is an info message") - """ - logger = logging.getLogger(name) - - # Avoid adding handlers multiple times - if logger.handlers: - return logger - - # Determine log level - log_level = _determine_log_level() - logger.setLevel(log_level) - - # Create formatter - formatter = logging.Formatter( - '%(asctime)s [%(levelname)s] [%(filename)s:%(lineno)d-%(funcName)s()] %(message)s' - ) - - # Create and configure stream handler - stream_handler = logging.StreamHandler() - stream_handler.setLevel(log_level) - stream_handler.setFormatter(formatter) - - # Add handler to logger - logger.addHandler(stream_handler) - - # Prevent propagation to root logger to avoid duplicate messages - logger.propagate = False - - return logger - - -def configure_logging(level: Optional[int] = None) -> None: - """ - Configure logging for the entire dftt_timecode package. - - This function can be called by users to manually set the log level - for all dftt_timecode loggers. - - Args: - level: Optional logging level (logging.DEBUG, logging.INFO, etc.). - If None, uses automatic detection based on git branch. - - Example: - >>> import logging - >>> from dftt_timecode.logging_config import configure_logging - >>> configure_logging(logging.WARNING) # Only show warnings and above - """ - if level is None: - level = _determine_log_level() - - # Configure all dftt_timecode loggers - for logger_name in logging.Logger.manager.loggerDict: - if logger_name.startswith('dftt_timecode'): - logger = logging.getLogger(logger_name) - logger.setLevel(level) - for handler in logger.handlers: - handler.setLevel(level) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..440645e --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,12 @@ +import logging +import os + + +def pytest_configure(config): + level_name = os.environ.get("DFTT_TEST_LOG_LEVEL", "DEBUG").upper() + level = getattr(logging, level_name, logging.DEBUG) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] [%(filename)s:%(lineno)d-%(funcName)s()] %(message)s", + ) + logging.getLogger("dftt_timecode").setLevel(level) diff --git a/test/test_logging_config.py b/test/test_logging_config.py deleted file mode 100644 index 6239923..0000000 --- a/test/test_logging_config.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Tests for logging configuration module. - -Tests the automatic log level detection based on git branch and -environment variable configuration. -""" - -import logging -import subprocess -from unittest.mock import patch - -from dftt_timecode.logging_config import ( - _determine_log_level, - _get_git_branch, - configure_logging, - get_logger, -) - - -class TestGitBranchDetection: - """Test git branch detection functionality.""" - - def test_get_git_branch_in_repo(self): - """Test that _get_git_branch returns a branch name when in a git repo.""" - branch = _get_git_branch() - # Should return a string (branch name) or None if not in repo - assert branch is None or isinstance(branch, str) - - @patch('subprocess.run') - def test_get_git_branch_success(self, mock_run): - """Test successful git branch detection.""" - mock_run.return_value.returncode = 0 - mock_run.return_value.stdout = 'main\n' - - branch = _get_git_branch() - assert branch == 'main' - - @patch('subprocess.run') - def test_get_git_branch_not_in_repo(self, mock_run): - """Test behavior when not in a git repository.""" - mock_run.return_value.returncode = 128 - mock_run.return_value.stdout = '' - - branch = _get_git_branch() - assert branch is None - - @patch('subprocess.run') - def test_get_git_branch_timeout(self, mock_run): - """Test behavior when git command times out.""" - mock_run.side_effect = subprocess.TimeoutExpired('git', 2) - - branch = _get_git_branch() - assert branch is None - - @patch('subprocess.run') - def test_get_git_branch_file_not_found(self, mock_run): - """Test behavior when git is not installed.""" - mock_run.side_effect = FileNotFoundError() - - branch = _get_git_branch() - assert branch is None - - -class TestLogLevelDetermination: - """Test log level determination logic.""" - - def test_determine_log_level_with_env_debug(self, monkeypatch): - """Test that DFTT_LOG_LEVEL env var sets DEBUG level.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'DEBUG') - level = _determine_log_level() - assert level == logging.DEBUG - - def test_determine_log_level_with_env_info(self, monkeypatch): - """Test that DFTT_LOG_LEVEL env var sets INFO level.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'INFO') - level = _determine_log_level() - assert level == logging.INFO - - def test_determine_log_level_with_env_warning(self, monkeypatch): - """Test that DFTT_LOG_LEVEL env var sets WARNING level.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'WARNING') - level = _determine_log_level() - assert level == logging.WARNING - - def test_determine_log_level_with_env_error(self, monkeypatch): - """Test that DFTT_LOG_LEVEL env var sets ERROR level.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'ERROR') - level = _determine_log_level() - assert level == logging.ERROR - - def test_determine_log_level_with_env_critical(self, monkeypatch): - """Test that DFTT_LOG_LEVEL env var sets CRITICAL level.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'CRITICAL') - level = _determine_log_level() - assert level == logging.CRITICAL - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_determine_log_level_with_invalid_env(self, mock_get_branch, monkeypatch): - """Test that invalid DFTT_LOG_LEVEL env var is ignored.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'INVALID') - # Mock git to return a dev branch for predictable testing - mock_get_branch.return_value = 'dev' - level = _determine_log_level() - # Should fall back to branch-based detection - assert level == logging.DEBUG - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_determine_log_level_main_branch(self, mock_get_branch, monkeypatch): - """Test that main branch gets INFO level.""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = 'main' - - level = _determine_log_level() - assert level == logging.INFO - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_determine_log_level_dev_branch(self, mock_get_branch, monkeypatch): - """Test that dev branch gets DEBUG level.""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = 'dev' - - level = _determine_log_level() - assert level == logging.DEBUG - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_determine_log_level_feature_branch(self, mock_get_branch, monkeypatch): - """Test that feature branches get DEBUG level.""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = 'feature/new-feature' - - level = _determine_log_level() - assert level == logging.DEBUG - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_determine_log_level_no_git(self, mock_get_branch, monkeypatch): - """Test that INFO is used when git info unavailable (built packages).""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = None - - level = _determine_log_level() - assert level == logging.INFO - - def test_env_var_overrides_branch(self, monkeypatch): - """Test that environment variable takes priority over branch detection.""" - monkeypatch.setenv('DFTT_LOG_LEVEL', 'ERROR') - # Even if we're on main branch, env var should win - level = _determine_log_level() - assert level == logging.ERROR - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_installed_package_defaults_to_info(self, mock_get_branch, monkeypatch): - """Test that installed packages (no git) default to INFO level.""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = None - - level = _determine_log_level() - # Built/installed packages should use INFO, not DEBUG - assert level == logging.INFO - - -class TestGetLogger: - """Test logger creation and configuration.""" - - def test_get_logger_returns_logger(self): - """Test that get_logger returns a Logger instance.""" - logger = get_logger('test_module') - assert isinstance(logger, logging.Logger) - - def test_get_logger_has_handler(self): - """Test that logger has at least one handler configured.""" - logger = get_logger('test_module_2') - assert len(logger.handlers) > 0 - - def test_get_logger_has_formatter(self): - """Test that logger's handler has a formatter configured.""" - logger = get_logger('test_module_3') - handler = logger.handlers[0] - assert handler.formatter is not None - - def test_get_logger_no_propagate(self): - """Test that logger doesn't propagate to root logger.""" - logger = get_logger('test_module_4') - assert logger.propagate is False - - def test_get_logger_idempotent(self): - """Test that calling get_logger multiple times doesn't add duplicate handlers.""" - logger1 = get_logger('test_module_5') - handler_count_1 = len(logger1.handlers) - - logger2 = get_logger('test_module_5') - handler_count_2 = len(logger2.handlers) - - assert logger1 is logger2 - assert handler_count_1 == handler_count_2 - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_get_logger_level_main_branch(self, mock_get_branch, monkeypatch): - """Test that logger has INFO level on main branch.""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = 'main' - - logger = get_logger('test_module_main') - assert logger.level == logging.INFO - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_get_logger_level_dev_branch(self, mock_get_branch, monkeypatch): - """Test that logger has DEBUG level on dev branch.""" - monkeypatch.delenv('DFTT_LOG_LEVEL', raising=False) - mock_get_branch.return_value = 'dev' - - logger = get_logger('test_module_dev') - assert logger.level == logging.DEBUG - - -class TestConfigureLogging: - """Test manual logging configuration.""" - - def test_configure_logging_with_level(self): - """Test that configure_logging sets specified level.""" - # Create a test logger first - logger = get_logger('dftt_timecode.test_config') - - # Configure to WARNING - configure_logging(logging.WARNING) - - # Check that the level was updated - assert logger.level == logging.WARNING - - @patch('dftt_timecode.logging_config._get_git_branch') - def test_configure_logging_without_level(self, mock_get_branch): - """Test that configure_logging uses automatic detection when level not specified.""" - # Create a test logger first - logger = get_logger('dftt_timecode.test_config_2') - - # Mock git to return main branch - mock_get_branch.return_value = 'main' - - # Configure without specifying level - configure_logging() - - # Should use branch-based detection (INFO for main) - assert logger.level == logging.INFO - - def test_configure_logging_updates_handlers(self): - """Test that configure_logging updates handler levels.""" - # Create a test logger first - logger = get_logger('dftt_timecode.test_config_3') - - # Configure to ERROR - configure_logging(logging.ERROR) - - # Check that handlers were updated - for handler in logger.handlers: - assert handler.level == logging.ERROR - - -class TestIntegration: - """Integration tests for logging system.""" - - def test_dftt_timecode_uses_logging_config(self): - """Test that DfttTimecode module uses the new logging config.""" - from dftt_timecode.core.dftt_timecode import logger - - # Should be a proper Logger instance - assert isinstance(logger, logging.Logger) - - # Should have handlers configured - assert len(logger.handlers) > 0 - - def test_logging_exports_from_main_package(self): - """Test that logging functions are exported from main package.""" - from dftt_timecode import configure_logging as conf - from dftt_timecode import get_logger as gl - - assert callable(conf) - assert callable(gl) - - def test_real_world_usage(self): - """Test a real-world usage scenario.""" - # Import and create a timecode - from dftt_timecode import DfttTimecode - - # This should not raise any errors - tc = DfttTimecode('01:00:00:00', fps=24) - assert tc is not None From a82018275cad1e6e48fe11b3f63ccfb0d4fb5418 Mon Sep 17 00:00:00 2001 From: WheheoHu Date: Tue, 12 May 2026 21:08:18 +0800 Subject: [PATCH 3/5] chore: bump to 1.0.0b4 with exception/logging cleanup Bundles the in-progress 1.0.0b4 work as a baseline: - Bump version to 1.0.0b4 in pyproject.toml. - Refactor exception handling across DfttTimecode and DfttTimeRange: raise DFTTError subclasses with descriptive messages instead of raising bare exception classes followed by separate logger.error calls. - Stop the silent SMPTE fallback in DfttTimecode.timecode_output() for unexpected conversion errors; only the defensive AttributeError guard remains. - Narrow exception wrapping in DfttTimeRange.offset / .extend / .__contains__ to wrap only DFTTError raised during internal DfttTimecode construction; other exception types now propagate. - Document all of the above in CHANGELOG.md under [1.0.0b4]. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 30 ++++ dftt_timecode/core/dftt_timecode.py | 93 ++++------- dftt_timecode/core/dftt_timerange.py | 233 +++++++++++++-------------- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 178 insertions(+), 182 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920308e..1bfff23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b4] + +### Removed + +- **Breaking:** Remove `configure_logging()` and `get_logger()` from the public API (`dftt_timecode.configure_logging`, `dftt_timecode.get_logger`) + + **破坏性变更:** 从公开 API 中移除 `configure_logging()` 和 `get_logger()` + +- **Breaking:** Remove the `dftt_timecode.logging_config` module. Logging is now configured directly inside core modules using a standard `NullHandler`, following the recommended library logging convention. Downstream code should configure logging via the standard `logging` module + + **破坏性变更:** 移除 `dftt_timecode.logging_config` 模块。日志现在在核心模块内部直接配置,并按照库日志最佳实践添加 `NullHandler`。下游代码应使用标准 `logging` 模块自行配置日志 + +### Changed + +- **Breaking:** `DfttTimecode.timecode_output()` no longer silently falls back to SMPTE when a conversion method raises an unexpected exception. Only the defensive `AttributeError` guard remains; other errors now propagate to the caller + + **破坏性变更:** `DfttTimecode.timecode_output()` 在转换方法抛出意外异常时,不再静默回退到 SMPTE 格式。仅保留对 `AttributeError` 的防御性回退,其它异常会正常抛给调用方 + +- **Breaking:** Narrow the exception wrapping in `DfttTimeRange.offset()`, `DfttTimeRange.extend()`, and `DfttTimeRange.__contains__()`. Previously a broad `except Exception` re-raised everything as `DFTTTimeRangeMethodError` / `DFTTTimeRangeTypeError`; now only `DFTTError` raised during internal `DfttTimecode` construction is wrapped, and other exception types propagate unchanged + + **破坏性变更:** 收窄 `DfttTimeRange.offset()`、`DfttTimeRange.extend()` 和 `DfttTimeRange.__contains__()` 中的异常包装范围。之前用宽泛的 `except Exception` 将所有异常重新包装;现在只包装内部构造 `DfttTimecode` 时抛出的 `DFTTError`,其它类型异常按原类型透传 + +- All `DFTTError` subclasses raised across `DfttTimecode` and `DfttTimeRange` now carry descriptive messages (`raise DFTTTimecodeOperatorError("...")`). Previously many sites raised the bare exception class with no message and emitted a separate `logger.error(...)` line, so `str(exc)` / `exc.args` were empty + + 所有在 `DfttTimecode` 和 `DfttTimeRange` 中抛出的 `DFTTError` 子类现在都附带描述性消息。之前多处只 `raise` 异常类本身(无消息)并额外打一条 `logger.error(...)` 日志,导致 `str(exc)` / `exc.args` 为空 + +- Stop logging an `ERROR` record immediately before raising a `DFTTError`. The exception itself is now the single source of truth; callers using log capture for error visibility will see fewer records + + 在抛出 `DFTTError` 前不再额外记录一条 `ERROR` 日志。异常本身作为唯一的错误信息来源;依赖日志捕获错误信息的调用方将看到更少的日志记录 + ## [1.0.0b3] ### Fixed diff --git a/dftt_timecode/core/dftt_timecode.py b/dftt_timecode/core/dftt_timecode.py index 55696c5..cde0f2b 100644 --- a/dftt_timecode/core/dftt_timecode.py +++ b/dftt_timecode/core/dftt_timecode.py @@ -327,7 +327,6 @@ def _(self, timecode_value: str, timecode_type:TimecodeType='auto', fps=24.0, dr } init_func=timecode_type_handler_map.get(timecode_type) if not init_func: - logger.error(f'Unknown timecode type: [{timecode_type}]') raise DFTTTimecodeTypeError(f'Unknown timecode type: [{timecode_type}]') init_func(timecode_value,minus_flag) @@ -342,9 +341,8 @@ def _(self, timecode_value: Fraction, timecode_type='time', fps=24.0, drop_frame self.__precise_time = timecode_value # 内部时间戳直接等于输入值 self.__apply_strict() else: - logger.error( + raise DFTTTimecodeTypeError( f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' logger.debug(instance_success_log) @@ -358,15 +356,14 @@ def _(self, timecode_value: int, timecode_type='frame', fps=24.0, drop_frame=Fal self.__fps * 86400) if self.__drop_frame else temp_frame_index % ( self.__nominal_fps * 86400) self.__precise_time = Fraction(temp_frame_index / self.__fps) - + elif timecode_type == 'time': self.__init_common(timecode_type,fps,drop_frame,strict) self.__precise_time = Fraction(timecode_value) # 内部时间戳直接等于输入值 self.__apply_strict() else: - logger.error( + raise DFTTTimecodeTypeError( f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' logger.debug(instance_success_log) @@ -377,9 +374,8 @@ def _(self, timecode_value: float, timecode_type='time', fps=24.0, drop_frame=Fa self.__precise_time = Fraction(timecode_value) # 内部时间戳直接等于输入值 self.__apply_strict() else: - logger.error( + raise DFTTTimecodeTypeError( f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' logger.debug(instance_success_log) @@ -391,9 +387,8 @@ def _(self, timecode_value: tuple, timecode_type='time', fps=24.0, drop_frame=Fa int(timecode_value[0]), int(timecode_value[1])) # 将tuple输入视为分数 self.__apply_strict() else: - logger.error( + raise DFTTTimecodeTypeError( f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' logger.debug(instance_success_log) @@ -405,9 +400,8 @@ def _(self, timecode_value: list, timecode_type='time', fps=24.0, drop_frame=Fal int(timecode_value[0]), int(timecode_value[1])) # 将list输入视为分数 self.__apply_strict() else: - logger.error( + raise DFTTTimecodeTypeError( f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - raise DFTTTimecodeTypeError instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' logger.debug(instance_success_log) @@ -815,13 +809,14 @@ def timecode_output(self, dest_type: TimecodeType = 'auto', output_part: int = 0 return self._convert_to_output_smpte(output_part) # Call the conversion method + func = getattr(self, method_name) try: - func = getattr(self, method_name) return str(func(output_part)) - except Exception as e: - # If the conversion method fails, log the error and fall back to SMPTE - logger.error( - f'Error during conversion to [{format_type}]: {type(e).__name__}: {e}. Falling back to SMPTE type') + except AttributeError as e: + # Defensive fallback: if the conversion method itself is missing an attribute + # (should not happen given the hasattr guard above), fall back to SMPTE. + logger.warning( + f'Conversion to [{format_type}] failed ({e}); falling back to SMPTE.') return self._convert_to_output_smpte(output_part) def set_fps(self, dest_fps: float, rounding: bool = True) -> 'DfttTimecode': @@ -959,8 +954,7 @@ def move_frame(self, frames: int) -> 'DfttTimecode': DfttTimecode: Self reference for method chaining """ if not isinstance(frames, int): - logger.error('Frames parameter must be an integer.') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError('Frames parameter must be an integer.') new_precise_time = self.__precise_time + Fraction(frames) / Fraction(self.__fps) @@ -977,8 +971,7 @@ def move_time(self, seconds: Union[float, Fraction,int]) -> 'DfttTimecode': DfttTimecode: Self reference for method chaining """ if not isinstance(seconds, (float, Fraction,int)): - logger.error('Seconds parameter must be a float or Fraction.') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError('Seconds parameter must be a float, int, or Fraction.') new_precise_time = self.__precise_time + Fraction(seconds) @@ -1055,9 +1048,8 @@ def __add__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi temp_sum = self.__precise_time + other.__precise_time self.__strict = self.__strict or other.__strict else: # 帧率不同不允许相加,报错 - logger.error( - 'Timecode addition requires exact same FPS.') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError( + 'Timecode addition requires exact same FPS and drop_frame status.') elif isinstance(other, int): # 帧 temp_sum = self.__precise_time + (other / self.__fps) elif isinstance(other, float): # 时间 @@ -1065,8 +1057,7 @@ def __add__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi elif isinstance(other, Fraction): # 时间 temp_sum = self.__precise_time + other else: - logger.error(f'Undefined addition with [{type(other)}].') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError(f'Undefined addition with [{type(other).__name__}].') temp_object = DfttTimecode( temp_sum, 'time', self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) @@ -1126,9 +1117,8 @@ def __sub__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi diff = self.__precise_time - other.__precise_time self.__strict = self.__strict or other.__strict else: - logger.error( - 'Timecode subtraction requires exact same FPS.') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError( + 'Timecode subtraction requires exact same FPS and drop_frame status.') elif isinstance(other, int): # 帧 diff = self.__precise_time - other / self.__fps elif isinstance(other, float): # 时间 @@ -1136,8 +1126,7 @@ def __sub__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi elif isinstance(other, Fraction): # 时间 diff = self.__precise_time - other else: - logger.error(f'Undefined subtraction with [{type(other)}].') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError(f'Undefined subtraction with [{type(other).__name__}].') temp_object = DfttTimecode( diff, 'time', self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) @@ -1152,8 +1141,7 @@ def __rsub__(self, other): # 运算符重载,减法,同理,int是帧,fl elif isinstance(other, Fraction): # 时间 diff = other - self.__precise_time else: - logger.error(f'Undefined subtraction with [{type(other)}].') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError(f'Undefined subtraction with [{type(other).__name__}].') temp_object = DfttTimecode( diff, 'time', self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) @@ -1186,9 +1174,8 @@ def __mul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': """ prod = self.__precise_time if isinstance(other, DfttTimecode): - logger.error( + raise DFTTTimecodeOperatorError( 'Timecode CANNOT multiply with another Timecode.') - raise DFTTTimecodeOperatorError elif isinstance(other, int): prod = self.__precise_time * other elif isinstance(other, float): @@ -1196,8 +1183,7 @@ def __mul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': elif isinstance(other, Fraction): prod = self.__precise_time * other else: - logger.error(f'Undefined multiplication with [{type(other)}].') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError(f'Undefined multiplication with [{type(other).__name__}].') temp_object = DfttTimecode( prod, 'time', self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) @@ -1244,9 +1230,8 @@ def __truediv__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': """ quo_time = self.__precise_time # quo_time是商(时间戳) if isinstance(other, DfttTimecode): - logger.error( - 'Timecode CANNOT be devided by another Timecode.') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError( + 'Timecode CANNOT be divided by another Timecode.') elif isinstance(other, int): # timecode与数相除,得到结果是timecode quo_time = self.__precise_time / other elif isinstance(other, float): # timecode与数相除,得到结果是timecode @@ -1254,21 +1239,18 @@ def __truediv__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': elif isinstance(other, Fraction): # timecode与数相除,得到结果是timecode quo_time = self.__precise_time / other else: - logger.error(f'Undefined division with [{type(other)}].') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError(f'Undefined division with [{type(other).__name__}].') temp_object = DfttTimecode( quo_time, 'time', self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) return temp_object def __rtruediv__(self, other): - if isinstance(other, int) or isinstance(other, float) or isinstance(other, Fraction): - logger.error( - 'Number CANNOT be devided by a Timecode.') - raise DFTTTimecodeOperatorError + if isinstance(other, (int, float, Fraction)): + raise DFTTTimecodeOperatorError( + 'Number CANNOT be divided by a Timecode.') else: - logger.error(f'Undefined division with [{type(other)}].') - raise DFTTTimecodeOperatorError + raise DFTTTimecodeOperatorError(f'Undefined division with [{type(other).__name__}].') def __eq__(self, other: object) -> bool: """Check equality with another timecode or numeric value. @@ -1310,8 +1292,7 @@ def __eq__(self, other: object) -> bool: elif isinstance(other, Fraction): return round(self.__precise_time, 5) == round(other, 5) else: - logger.error(f'CANNOT compare with data type [{type(other)}].') - raise DFTTTimecodeTypeError + raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') def __ne__(self, other: object) -> bool: """Check inequality with another timecode or numeric value. @@ -1365,8 +1346,7 @@ def __lt__(self, other: Union['DfttTimecode', int, float, Fraction]) -> bool: elif isinstance(other, Fraction): return round(self.__precise_time, 5) < round(other, 5) else: - logger.error(f'CANNOT compare with data type [{type(other)}].') - raise DFTTTimecodeTypeError + raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') def __le__(self, other): # 详见__eq__ if isinstance(other, DfttTimecode): @@ -1381,8 +1361,7 @@ def __le__(self, other): # 详见__eq__ elif isinstance(other, Fraction): return round(self.__precise_time, 5) <= round(other, 5) else: - logger.error(f'CANNOT compare with data type [{type(other)}].') - raise DFTTTimecodeTypeError + raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') def __gt__(self, other): # 详见__eq__ if isinstance(other, DfttTimecode): @@ -1397,8 +1376,7 @@ def __gt__(self, other): # 详见__eq__ elif isinstance(other, Fraction): return round(self.__precise_time, 5) > round(other, 5) else: - logger.error(f'CANNOT compare with data type [{type(other)}].') - raise DFTTTimecodeTypeError + raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') def __ge__(self, other): # 详见__eq__ if isinstance(other, DfttTimecode): @@ -1413,8 +1391,7 @@ def __ge__(self, other): # 详见__eq__ elif isinstance(other, Fraction): return round(self.__precise_time, 5) >= round(other, 5) else: - logger.error(f'CANNOT compare with data type [{type(other)}].') - raise DFTTTimecodeTypeError + raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') def __neg__(self) -> 'DfttTimecode': """Return the negation of this timecode. diff --git a/dftt_timecode/core/dftt_timerange.py b/dftt_timecode/core/dftt_timerange.py index 56e4dd7..549f94a 100644 --- a/dftt_timecode/core/dftt_timerange.py +++ b/dftt_timecode/core/dftt_timerange.py @@ -158,10 +158,10 @@ def __init__( # Validate 24h constraint if self.__strict_24h and abs(self.__precise_duration) > self.TIME_24H_SECONDS: - logger.error( - f"Duration {abs(self.__precise_duration)}s exceeds 24 hours ({self.TIME_24H_SECONDS}s) in strict mode" + raise DFTTTimeRangeValueError( + f"Duration {abs(self.__precise_duration)}s exceeds 24 hours " + f"({self.TIME_24H_SECONDS}s) in strict_24h mode" ) - raise DFTTTimeRangeValueError("Duration exceeds 24 hours in strict mode") logger.debug( f"TimeRange created: start={float(self.__start_precise_time):.3f}s, " @@ -174,11 +174,9 @@ def _init_from_timecodes(self, start_tc, end_tc): # Convert inputs to DfttTimecode objects if isinstance(start_tc, DfttTimecode) and isinstance(end_tc, DfttTimecode): if start_tc.fps != end_tc.fps: - logger.error( - f"FPS mismatch: start_tc fps={start_tc.fps}, end_tc fps={end_tc.fps}" - ) raise DFTTTimeRangeFPSError( - "FPS mismatch between start and end timecodes" + f"FPS mismatch between start and end timecodes: " + f"start={start_tc.fps}, end={end_tc.fps}" ) self.__fps = start_tc.fps start_precise = start_tc.precise_timestamp @@ -220,8 +218,9 @@ def _init_from_timecodes(self, start_tc, end_tc): self.__precise_duration += self.TIME_24H_SECONDS if self.__precise_duration == 0: - logger.error("Cannot create zero-length timerange") - raise DFTTTimeRangeValueError("Time range cannot be zero-length!") + raise DFTTTimeRangeValueError( + "Time range cannot be zero-length (start equals end)!" + ) self.__start_precise_time = start_precise @@ -309,36 +308,38 @@ def offset(self, offset_value: Union[float, DfttTimecode, str, int]) -> "DfttTim Note: In strict_24h mode, the new start time wraps around at 24 hours. """ - try: - if isinstance(offset_value, float): - offset_precise = Fraction(offset_value) - elif isinstance(offset_value, DfttTimecode): - offset_precise = offset_value.precise_timestamp - else: + if isinstance(offset_value, float): + offset_precise = Fraction(offset_value) + elif isinstance(offset_value, DfttTimecode): + offset_precise = offset_value.precise_timestamp + else: + try: offset_tc = DfttTimecode(offset_value, fps=self.__fps) - offset_precise = offset_tc.precise_timestamp + except DFTTError as e: + raise DFTTTimeRangeMethodError( + f"Invalid offset value [{offset_value!r}]: {e}" + ) from e + offset_precise = offset_tc.precise_timestamp - new_start = self.__start_precise_time + offset_precise + new_start = self.__start_precise_time + offset_precise - # Handle 24h constraint - if self.__strict_24h: - new_start = new_start % self.TIME_24H_SECONDS + # Handle 24h constraint + if self.__strict_24h: + new_start = new_start % self.TIME_24H_SECONDS - logger.debug( - f"Offset timerange by {float(offset_precise):.3f}s: " - f"old_start={float(self.__start_precise_time):.3f}s, " - f"new_start={float(new_start):.3f}s" - ) + logger.debug( + f"Offset timerange by {float(offset_precise):.3f}s: " + f"old_start={float(self.__start_precise_time):.3f}s, " + f"new_start={float(new_start):.3f}s" + ) - return DfttTimeRange( - start_precise_time=new_start, - precise_duration=self.__precise_duration, - forward=self.__forward, - fps=self.__fps, - strict_24h=self.__strict_24h, - ) - except Exception: - raise DFTTTimeRangeMethodError(f"Invalid offset value {offset_value}") + return DfttTimeRange( + start_precise_time=new_start, + precise_duration=self.__precise_duration, + forward=self.__forward, + fps=self.__fps, + strict_24h=self.__strict_24h, + ) def extend(self, extend_value: Union[int, float, DfttTimecode, str]) -> "DfttTimeRange": """Extend duration (positive value increases duration). @@ -373,49 +374,47 @@ def extend(self, extend_value: Union[int, float, DfttTimecode, str]) -> "DfttTim Note: The direction (forward/backward) affects how extension is applied. """ - try: - if isinstance(extend_value, (int, float)): - extend_precise = Fraction(extend_value) - elif isinstance(extend_value, DfttTimecode): - extend_precise = extend_value.precise_timestamp - else: + if isinstance(extend_value, (int, float)): + extend_precise = Fraction(extend_value) + elif isinstance(extend_value, DfttTimecode): + extend_precise = extend_value.precise_timestamp + else: + try: extend_tc = DfttTimecode(extend_value, fps=self.__fps) - extend_precise = extend_tc.precise_timestamp + except DFTTError as e: + raise DFTTTimeRangeMethodError( + f"Invalid extend value [{extend_value!r}]: {e}" + ) from e + extend_precise = extend_tc.precise_timestamp + + new_duration = self.__precise_duration + ( + extend_precise if self.__forward else -extend_precise + ) - new_duration = self.__precise_duration + ( - extend_precise if self.__forward else -extend_precise + if new_duration == 0: + raise DFTTTimeRangeValueError( + "Cannot create zero-length timerange via extend" ) - if new_duration == 0: - logger.error("Cannot create zero-length timerange via extend") - raise DFTTTimeRangeValueError("Cannot create zero-length timerange") - - # Handle 24h constraint - if self.__strict_24h and abs(new_duration) > self.TIME_24H_SECONDS: - logger.error( - f"Extended duration {abs(new_duration):.3f}s exceeds 24 hours in strict mode" - ) - raise DFTTTimeRangeValueError( - "Duration exceeds 24 hours in strict mode" - ) - - logger.debug( - f"Extend timerange by {float(extend_precise):.3f}s: " - f"old_duration={float(self.__precise_duration):.3f}s, " - f"new_duration={float(new_duration):.3f}s" + # Handle 24h constraint + if self.__strict_24h and abs(new_duration) > self.TIME_24H_SECONDS: + raise DFTTTimeRangeValueError( + f"Extended duration {abs(new_duration):.3f}s exceeds 24 hours in strict_24h mode" ) - return DfttTimeRange( - start_precise_time=self.__start_precise_time, - precise_duration=new_duration, - forward=self.__forward, - fps=self.__fps, - strict_24h=self.__strict_24h, - ) - except Exception as e: - if isinstance(e, DFTTTimeRangeValueError): - raise - raise DFTTTimeRangeMethodError("Invalid extend value") + logger.debug( + f"Extend timerange by {float(extend_precise):.3f}s: " + f"old_duration={float(self.__precise_duration):.3f}s, " + f"new_duration={float(new_duration):.3f}s" + ) + + return DfttTimeRange( + start_precise_time=self.__start_precise_time, + precise_duration=new_duration, + forward=self.__forward, + fps=self.__fps, + strict_24h=self.__strict_24h, + ) def shorten(self, shorten_value: Union[int, float, DfttTimecode, str]) -> "DfttTimeRange": """Shorten duration (positive value decreases duration). @@ -534,20 +533,19 @@ def retime(self, retime_factor: Union[int, float, Fraction]) -> "DfttTimeRange": - Can also use the ``*`` operator for the same effect """ if not isinstance(retime_factor, (int, float, Fraction)): - logger.error(f"Retime factor must be numeric, got {type(retime_factor)}") - raise DFTTTimeRangeTypeError("Retime factor must be numeric") + raise DFTTTimeRangeTypeError( + f"Retime factor must be numeric, got {type(retime_factor).__name__}" + ) if retime_factor == 0: - logger.error("Cannot retime to zero duration") raise DFTTTimeRangeValueError("Cannot retime to zero duration") new_duration = self.__precise_duration * Fraction(retime_factor) if self.__strict_24h and abs(new_duration) > self.TIME_24H_SECONDS: - logger.error( - f"Retimed duration {abs(new_duration):.3f}s exceeds 24 hours in strict mode" + raise DFTTTimeRangeValueError( + f"Retimed duration {abs(new_duration):.3f}s exceeds 24 hours in strict_24h mode" ) - raise DFTTTimeRangeValueError("Duration exceeds 24 hours in strict mode") logger.debug( f"Retime timerange by factor {retime_factor}: " @@ -601,8 +599,9 @@ def separate(self, num_parts: int) -> List["DfttTimeRange"]: - Useful for splitting work into parallel chunks or creating segments """ if num_parts < 2: - logger.error(f"Cannot separate into {num_parts} parts, must be >= 2") - raise DFTTTimeRangeValueError("Must separate into at least 2 parts") + raise DFTTTimeRangeValueError( + f"Cannot separate into {num_parts} parts, must be >= 2" + ) part_duration = self.__precise_duration / num_parts logger.debug( @@ -681,9 +680,11 @@ def contains(self, item: Union[DfttTimecode, 'DfttTimeRange', str, int, float], else: try: tc = DfttTimecode(item, fps=self.__fps) - return self.contains(tc) - except DFTTError: - raise DFTTTimeRangeTypeError("Invalid item type for contains check") + except DFTTError as e: + raise DFTTTimeRangeTypeError( + f"Invalid item type for contains check [{item!r}]: {e}" + ) from e + return self.contains(tc) def intersect(self, other: "DfttTimeRange") -> Optional["DfttTimeRange"]: """Calculate intersection of two timeranges (AND operation). @@ -718,28 +719,21 @@ def intersect(self, other: "DfttTimeRange") -> Optional["DfttTimeRange"]: - Strict_24h is True only if both input timeranges have it enabled """ if not isinstance(other, DfttTimeRange): - logger.error(f"Can only intersect with DfttTimeRange, got {type(other)}") raise DFTTTimeRangeTypeError( - "Can only intersect with another DfttTimeRange" + f"Can only intersect with another DfttTimeRange, got {type(other).__name__}" ) if self.__forward != other.forward: - logger.error( + raise DFTTTimeRangeMethodError( f"Cannot intersect timeranges with different directions: " f"self.forward={self.__forward}, other.forward={other.forward}" ) - raise DFTTTimeRangeMethodError( - "Cannot intersect timeranges with different directions" - ) if self.__fps != other.fps: - logger.error( + raise DFTTTimeRangeFPSError( f"Cannot intersect timeranges with different FPS: " f"self.fps={self.__fps}, other.fps={other.fps}" ) - raise DFTTTimeRangeFPSError( - "Cannot intersect timeranges with different FPS" - ) # Calculate intersection bounds if self.__forward: @@ -838,24 +832,21 @@ def union(self, other: "DfttTimeRange") -> "DfttTimeRange": - :meth:`add`: Add durations (different from union) """ if not isinstance(other, DfttTimeRange): - logger.error(f"Can only union with DfttTimeRange, got {type(other)}") - raise DFTTTimeRangeTypeError("Can only union with another DfttTimeRange") + raise DFTTTimeRangeTypeError( + f"Can only union with another DfttTimeRange, got {type(other).__name__}" + ) if self.__forward != other.forward: - logger.error( + raise DFTTTimeRangeMethodError( f"Cannot union timeranges with different directions: " f"self.forward={self.__forward}, other.forward={other.forward}" ) - raise DFTTTimeRangeMethodError( - "Cannot union timeranges with different directions" - ) if self.__fps != other.fps: - logger.error( + raise DFTTTimeRangeFPSError( f"Cannot union timeranges with different FPS: " f"self.fps={self.__fps}, other.fps={other.fps}" ) - raise DFTTTimeRangeFPSError("Cannot union timeranges with different FPS") # Check for overlap or adjacency if self.intersect(other) is None: @@ -865,24 +856,20 @@ def union(self, other: "DfttTimeRange") -> "DfttTimeRange": self.end_precise_time == other.start_precise_time or other.end_precise_time == self.__start_precise_time ): - logger.error( - "Cannot union non-overlapping, non-adjacent timeranges: " - f"self=[{float(self.__start_precise_time):.3f}s, {float(self.end_precise_time):.3f}s], " - f"other=[{float(other.start_precise_time):.3f}s, {float(other.end_precise_time):.3f}s]" - ) raise DFTTTimeRangeMethodError( - "Cannot union non-overlapping, non-adjacent timeranges" + f"Cannot union non-overlapping, non-adjacent timeranges: " + f"self=[{float(self.__start_precise_time):.3f}s, " + f"{float(self.end_precise_time):.3f}s], " + f"other=[{float(other.start_precise_time):.3f}s, " + f"{float(other.end_precise_time):.3f}s]" ) else: if not ( self.end_precise_time == other.start_precise_time or other.end_precise_time == self.__start_precise_time ): - logger.error( - "Cannot union non-overlapping, non-adjacent timeranges (backward)" - ) raise DFTTTimeRangeMethodError( - "Cannot union non-overlapping, non-adjacent timeranges" + "Cannot union non-overlapping, non-adjacent timeranges (backward)" ) # Calculate union bounds @@ -944,15 +931,15 @@ def add(self, other: "DfttTimeRange") -> "DfttTimeRange": - This is different from :meth:`union` which combines overlapping ranges """ if not isinstance(other, DfttTimeRange): - logger.error(f"Can only add DfttTimeRange, got {type(other)}") - raise DFTTTimeRangeTypeError("Can only add another DfttTimeRange") + raise DFTTTimeRangeTypeError( + f"Can only add another DfttTimeRange, got {type(other).__name__}" + ) if self.__fps != other.fps: - logger.error( + raise DFTTTimeRangeFPSError( f"Cannot add timeranges with different FPS: " f"self.fps={self.__fps}, other.fps={other.fps}" ) - raise DFTTTimeRangeFPSError("Cannot add timeranges with different FPS") # Direction sensitive addition if self.__forward == other.forward: @@ -961,8 +948,9 @@ def add(self, other: "DfttTimeRange") -> "DfttTimeRange": new_duration = self.__precise_duration - other.precise_duration if new_duration == 0: - logger.error("Add operation resulted in zero-length timerange") - raise DFTTTimeRangeValueError("Cannot create zero-length timerange") + raise DFTTTimeRangeValueError( + "Add operation resulted in zero-length timerange" + ) logger.debug( f"Add timerange: same_direction={self.__forward == other.forward}, " @@ -1019,15 +1007,15 @@ def subtract(self, other: "DfttTimeRange") -> "DfttTimeRange": - Can result in zero-length error if durations are equal """ if not isinstance(other, DfttTimeRange): - logger.error(f"Can only subtract DfttTimeRange, got {type(other)}") - raise DFTTTimeRangeTypeError("Can only subtract another DfttTimeRange") + raise DFTTTimeRangeTypeError( + f"Can only subtract another DfttTimeRange, got {type(other).__name__}" + ) if self.__fps != other.fps: - logger.error( + raise DFTTTimeRangeFPSError( f"Cannot subtract timeranges with different FPS: " f"self.fps={self.__fps}, other.fps={other.fps}" ) - raise DFTTTimeRangeFPSError("Cannot subtract timeranges with different FPS") # Direction sensitive subtraction if self.__forward == other.forward: @@ -1036,8 +1024,9 @@ def subtract(self, other: "DfttTimeRange") -> "DfttTimeRange": new_duration = self.__precise_duration + other.precise_duration if new_duration == 0: - logger.error("Subtract operation resulted in zero-length timerange") - raise DFTTTimeRangeValueError("Cannot create zero-length timerange") + raise DFTTTimeRangeValueError( + "Subtract operation resulted in zero-length timerange" + ) logger.debug( f"Subtract timerange: same_direction={self.__forward == other.forward}, " diff --git a/pyproject.toml b/pyproject.toml index 6d1cfa8..8388fdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "dftt-timecode" -version = "1.0.0b3" +version = "1.0.0b4" description = "Timecode library for film and TV industry, supports HFR and a bunch of cool features" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 2e493ee..750a852 100644 --- a/uv.lock +++ b/uv.lock @@ -170,7 +170,7 @@ wheels = [ [[package]] name = "dftt-timecode" -version = "1.0.0b3" +version = "1.0.0b4" source = { editable = "." } [package.dev-dependencies] From cc4757269d296a0962029cec368676c5831e754e Mon Sep 17 00:00:00 2001 From: WheheoHu Date: Tue, 12 May 2026 22:29:56 +0800 Subject: [PATCH 4/5] Refactor DfttTimecode initialization: enforce fail-loud behavior and remove class-level state defaults --- CHANGELOG.md | 18 ++++++ dftt_timecode/core/dftt_timecode.py | 19 ++---- test/test_init_fail_loud.py | 96 +++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 test/test_init_fail_loud.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfff23..d5a8b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0b4] +### Fixed + +- Initialization failures in `DfttTimecode` no longer leave behind a half-built object that silently behaves like a "value-0 timecode at 24 fps". The five class-level state defaults (`__fps`, `__nominal_fps`, `__drop_frame`, `__strict`, `__precise_time`) that masked missing instance attributes have been removed; any access to state on a partial instance now raises `AttributeError` + + `DfttTimecode` 初始化失败时不再遗留一个表现为「24fps 下零值时码」的半构造对象。原本掩盖未赋值实例属性的五个类级别默认值(`__fps`、`__nominal_fps`、`__drop_frame`、`__strict`、`__precise_time`)已被移除;对半构造实例的状态访问现在会抛出 `AttributeError` + ### Removed - **Breaking:** Remove `configure_logging()` and `get_logger()` from the public API (`dftt_timecode.configure_logging`, `dftt_timecode.get_logger`) @@ -19,6 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **破坏性变更:** 移除 `dftt_timecode.logging_config` 模块。日志现在在核心模块内部直接配置,并按照库日志最佳实践添加 `NullHandler`。下游代码应使用标准 `logging` 模块自行配置日志 +- **Breaking:** Remove the `DfttTimecode(existing_tc)` passthrough constructor introduced in 0.0.10. Passing an existing `DfttTimecode` to the constructor now raises `DFTTTimecodeTypeError` like any other unsupported input type. The previous behavior returned the same instance unchanged (`DfttTimecode(tc) is tc`), which created an aliasing footgun when the result was subsequently passed to mutators like `set_fps()`. Callers that want to reuse an instance should pass it directly; callers that want an independent copy should construct from a primitive representation such as `precise_timestamp`, `framecount`, or a string form + + **破坏性变更:** 移除 0.0.10 引入的 `DfttTimecode(existing_tc)` 透传构造行为。现在向构造器传入已有的 `DfttTimecode` 会和其它不支持的输入类型一样抛出 `DFTTTimecodeTypeError`。原行为返回的是同一个实例(`DfttTimecode(tc) is tc`),后续若调用 `set_fps()` 等就地修改方法会出现别名陷阱。需要复用实例的调用方请直接使用原对象;需要独立副本的调用方请改用 `precise_timestamp`、`framecount` 或字符串等基本表示重新构造 + +- **Breaking (latent):** Remove five class-level state attributes on `DfttTimecode` (`__fps`, `__nominal_fps`, `__drop_frame`, `__strict`, `__precise_time`). These were never part of the documented API but were observable via `vars(DfttTimecode)` / class-attribute inspection. See the corresponding Fixed entry above for the user-visible behavior change + + **破坏性变更(隐性):** 移除 `DfttTimecode` 上的五个类级别状态属性(`__fps`、`__nominal_fps`、`__drop_frame`、`__strict`、`__precise_time`)。它们从未作为文档化 API 公开,但可通过 `vars(DfttTimecode)` 等方式观测到。用户可见的行为变化见上方 Fixed 条目 + ### Changed - **Breaking:** `DfttTimecode.timecode_output()` no longer silently falls back to SMPTE when a conversion method raises an unexpected exception. Only the defensive `AttributeError` guard remains; other errors now propagate to the caller @@ -37,6 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 在抛出 `DFTTError` 前不再额外记录一条 `ERROR` 日志。异常本身作为唯一的错误信息来源;依赖日志捕获错误信息的调用方将看到更少的日志记录 +- **Breaking:** The base `DfttTimecode.__init__` overload now raises `DFTTTimecodeTypeError` for unsupported input types (previously raised bare `TypeError`). Callers using `try / except DFTTError:` will now catch this case alongside other library errors + + **破坏性变更:** `DfttTimecode.__init__` 的 dispatch 兜底分支在收到不支持的输入类型时改为抛出 `DFTTTimecodeTypeError`(原为裸 `TypeError`)。使用 `try / except DFTTError:` 的调用方现在会一并捕获到该情况 + ## [1.0.0b3] ### Fixed diff --git a/dftt_timecode/core/dftt_timecode.py b/dftt_timecode/core/dftt_timecode.py index cde0f2b..0327ae8 100644 --- a/dftt_timecode/core/dftt_timecode.py +++ b/dftt_timecode/core/dftt_timecode.py @@ -142,18 +142,7 @@ class DfttTimecode: - :mod:`dftt_timecode.error`: Custom exception classes """ __type: TimecodeType = 'time' - __fps = 24.0 # 帧率 - __nominal_fps = 24 # 名义帧率(无小数,进一法取整) - __drop_frame = False # 是否丢帧Dropframe(True为丢帧,False为不丢帧) - __strict = True # 严格模式,默认为真,在该模式下不允许超出24或小于0的时码,将自动平移至0-24范围内,例如-1小时即为23小时,25小时即为1小时 - __precise_time = Fraction(0) # 精准时间戳,是所有时码类对象的工作基础 - - def __new__(cls, timecode_value=0, timecode_type='auto', fps=24.0, drop_frame=False, strict=True): - if isinstance(timecode_value, DfttTimecode): - return timecode_value - else: - return super(DfttTimecode, cls).__new__(cls) - + def __validate_drop_frame(self, drop_frame: bool, fps: float) -> bool: if round(fps, 2) % 29.97 == 0: # FPS为29.97以及倍数时候,尊重drop_frame参数(for 29.97/59.94/119.88 NDF) @@ -296,8 +285,10 @@ def __init_common(self, timecode_type: TimecodeType, fps: float, drop_frame: boo self.__strict = strict @singledispatchmethod - def __init__(self, timecode_value, timecode_type, fps, drop_frame, strict): # 构造函数 - raise TypeError(f"Unsupported timecode value type: {type(timecode_value)}") + def __init__(self, timecode_value, timecode_type='auto', fps=24.0, drop_frame=False, strict=True): # 构造函数 + raise DFTTTimecodeTypeError( + f"Unsupported timecode value type: {type(timecode_value).__name__}" + ) @__init__.register # 若传入的TC值为字符串,则调用此函数 def _(self, timecode_value: str, timecode_type:TimecodeType='auto', fps=24.0, drop_frame=False, strict=True): diff --git a/test/test_init_fail_loud.py b/test/test_init_fail_loud.py new file mode 100644 index 0000000..0ea7d64 --- /dev/null +++ b/test/test_init_fail_loud.py @@ -0,0 +1,96 @@ +"""Tests locking in fail-loud initialization behavior for DfttTimecode and DfttTimeRange. + +These tests guard against silent fallbacks on partial construction. Construction +is forced via __new__ + a bare __init__ call inside a pytest.raises block so we +can inspect the half-built object. Real callers never see such an object, but +class-level defaults made silent corruption possible if anyone caught the +exception and continued using the partially constructed instance. +""" + +import pytest + +from dftt_timecode import DfttTimecode, DfttTimeRange +from dftt_timecode.error import DFTTTimecodeTypeError, DFTTTimeRangeValueError + + +class TestDfttTimecodePartialInitFailsLoud: + """If __init__ raises mid-way, accessing state attributes must raise + AttributeError instead of silently returning a class-level zero default.""" + + def test_string_init_failure_does_not_leave_zero_precise_time(self): + instance = DfttTimecode.__new__(DfttTimecode) + with pytest.raises(DFTTTimecodeTypeError): + instance.__init__("garbage-not-a-timecode", "auto", 24.0, False, True) + with pytest.raises(AttributeError): + _ = instance.precise_timestamp + + def test_string_init_failure_does_not_leave_zero_timestamp(self): + instance = DfttTimecode.__new__(DfttTimecode) + with pytest.raises(DFTTTimecodeTypeError): + instance.__init__("garbage-not-a-timecode", "auto", 24.0, False, True) + with pytest.raises(AttributeError): + _ = instance.timestamp + + def test_string_init_failure_does_not_leave_zero_framecount(self): + instance = DfttTimecode.__new__(DfttTimecode) + with pytest.raises(DFTTTimecodeTypeError): + instance.__init__("garbage-not-a-timecode", "auto", 24.0, False, True) + with pytest.raises(AttributeError): + _ = instance.framecount + + def test_fresh_new_object_has_no_state(self): + """An object returned by __new__ but never __init__'d has no instance + attributes at all. Property access must raise AttributeError.""" + instance = DfttTimecode.__new__(DfttTimecode) + with pytest.raises(AttributeError): + _ = instance.fps + with pytest.raises(AttributeError): + _ = instance.precise_timestamp + + +class TestDfttTimecodeBaseDispatchRejection: + """Unsupported value types must raise DFTTTimecodeTypeError, not bare TypeError.""" + + def test_unsupported_dict_raises_dftt_error(self): + with pytest.raises(DFTTTimecodeTypeError): + DfttTimecode({"not": "supported"}) + + def test_unsupported_type_message_names_the_type(self): + with pytest.raises(DFTTTimecodeTypeError) as excinfo: + DfttTimecode({"not": "supported"}) + assert "dict" in str(excinfo.value) + + +class TestDfttTimecodePassthroughRemoved: + """The DfttTimecode(existing_tc) passthrough (CHANGELOG 0.0.10) is removed + in 1.0.0b4. Constructing from an existing DfttTimecode must now raise + DFTTTimecodeTypeError. Callers that want to reuse an instance should pass + it directly; callers that want a copy should construct from a primitive + representation (precise_timestamp, framecount, or a string form).""" + + def test_constructing_from_existing_dftt_timecode_raises(self): + original = DfttTimecode("01:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + with pytest.raises(DFTTTimecodeTypeError): + DfttTimecode(original) + + def test_passthrough_removal_message_names_dftttimecode(self): + original = DfttTimecode("01:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + with pytest.raises(DFTTTimecodeTypeError) as excinfo: + DfttTimecode(original) + assert "DfttTimecode" in str(excinfo.value) + + +class TestDfttTimeRangePartialInitFailsLoud: + """DfttTimeRange has no class-level state defaults. Verify partial init + raises AttributeError on subsequent state access, locking in the invariant.""" + + def test_invalid_construction_raises(self): + with pytest.raises(DFTTTimeRangeValueError): + DfttTimeRange() # neither tc pair nor precise pair provided + + def test_partial_init_object_has_no_duration(self): + instance = DfttTimeRange.__new__(DfttTimeRange) + with pytest.raises(DFTTTimeRangeValueError): + instance.__init__() + with pytest.raises(AttributeError): + _ = instance.precise_duration From 195bdc0a146abd414ccf4a0a009d4681441988ee Mon Sep 17 00:00:00 2001 From: WheheoHu Date: Tue, 12 May 2026 22:40:22 +0800 Subject: [PATCH 5/5] Refactor error messages and assertions for clarity and consistency - Simplified error message formatting in DfttTimeRange class to single-line raises. - Updated assertions in test cases to use more concise boolean expressions. - Added configuration for Ruff and Ty to pyproject.toml for linting and type checking. --- dftt_timecode/core/dftt_timecode.py | 593 +++++++++++++++------------ dftt_timecode/core/dftt_timerange.py | 40 +- pyproject.toml | 7 + test/test_dftt_timerange.py | 28 +- 4 files changed, 368 insertions(+), 300 deletions(-) diff --git a/dftt_timecode/core/dftt_timecode.py b/dftt_timecode/core/dftt_timecode.py index 0327ae8..871ff10 100644 --- a/dftt_timecode/core/dftt_timecode.py +++ b/dftt_timecode/core/dftt_timecode.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) -TimecodeType: TypeAlias = Literal['smpte', 'srt', 'dlp', 'ffmpeg', 'fcpx', 'frame', 'time', 'auto'] +TimecodeType: TypeAlias = Literal["smpte", "srt", "dlp", "ffmpeg", "fcpx", "frame", "time", "auto"] """Type alias for supported timecode format types. Available formats: @@ -141,7 +141,8 @@ class DfttTimecode: - :mod:`dftt_timecode.pattern`: Regex patterns for format validation - :mod:`dftt_timecode.error`: Custom exception classes """ - __type: TimecodeType = 'time' + + __type: TimecodeType = "time" def __validate_drop_frame(self, drop_frame: bool, fps: float) -> bool: if round(fps, 2) % 29.97 == 0: @@ -150,89 +151,110 @@ def __validate_drop_frame(self, drop_frame: bool, fps: float) -> bool: else: return round(fps, 2) % 23.98 == 0 - def __detect_timecode_type(self,timecode_value)->TimecodeType: + def __detect_timecode_type(self, timecode_value) -> TimecodeType: if SMPTE_NDF_REGEX.match(timecode_value): # SMPTE NDF 强制DF为False if self.__drop_frame: - raise DFTTTimecodeInitializationError(f'Init Timecode Failed: Timecode value [{timecode_value}] DONOT match drop_frame status [{self.__drop_frame}]! Check input.') - return 'smpte' + raise DFTTTimecodeInitializationError( + f"Init Timecode Failed: Timecode value [{timecode_value}] DONOT match drop_frame status [{self.__drop_frame}]! Check input." + ) + return "smpte" elif SMPTE_DF_REGEX.match(timecode_value): - # 判断丢帧状态与帧率是否匹配 不匹配则强制转换 if not self.__drop_frame: - raise DFTTTimecodeInitializationError(f'Init Timecode Failed: Timecode value [{timecode_value}] DONOT match drop_frame status [{self.__drop_frame}]! Check input.') - return 'smpte' + raise DFTTTimecodeInitializationError( + f"Init Timecode Failed: Timecode value [{timecode_value}] DONOT match drop_frame status [{self.__drop_frame}]! Check input." + ) + return "smpte" elif SRT_REGEX.match(timecode_value): - return 'srt' + return "srt" elif FFMPEG_REGEX.match(timecode_value): - return 'ffmpeg' + return "ffmpeg" elif FCPX_REGEX.match(timecode_value): - return 'fcpx' + return "fcpx" elif FRAME_REGEX.match(timecode_value): - return 'frame' + return "frame" elif TIME_REGEX.match(timecode_value): - return 'time' + return "time" else: - raise DFTTTimecodeTypeError(f'Cannot detect timecode type for value [{timecode_value}]. No matching pattern found.') + raise DFTTTimecodeTypeError( + f"Cannot detect timecode type for value [{timecode_value}]. No matching pattern found." + ) + def __apply_strict(self) -> None: """Apply 24h wraparound if strict mode enabled""" if self.__strict: self.__precise_time %= 86400 - - @staticmethod - def _ensure_match(pattern:re.Pattern[str], timecode_value:str, timecode_type:str) -> re.Match[str]: - if not (match_result:=pattern.match(timecode_value)): - raise DFTTTimecodeTypeError(f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') + + @staticmethod + def _ensure_match( + pattern: re.Pattern[str], timecode_value: str, timecode_type: str + ) -> re.Match[str]: + if not (match_result := pattern.match(timecode_value)): + raise DFTTTimecodeTypeError( + f"Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input." + ) return match_result - - - def __init_smpte(self, timecode_value: str,minus_flag:bool): - match = self._ensure_match(SMPTE_REGEX, timecode_value, 'smpte') - hh,mm,ss,ff = map(int,match.groups(default='0')) # 将捕获的时分秒帧转换为整数,缺失部分默认为0 + + def __init_smpte(self, timecode_value: str, minus_flag: bool): + match = self._ensure_match(SMPTE_REGEX, timecode_value, "smpte") + hh, mm, ss, ff = map( + int, match.groups(default="0") + ) # 将捕获的时分秒帧转换为整数,缺失部分默认为0 if ff > self.__nominal_fps - 1: # 判断输入帧号在当前帧率下是否合法 - raise DFTTTimecodeValueError(f'This timecode: [{timecode_value}] is illegal under given params, check your input!') + raise DFTTTimecodeValueError( + f"This timecode: [{timecode_value}] is illegal under given params, check your input!" + ) if not self.__drop_frame: # 时码丢帧处理逻辑 - frame_index = ff + self.__nominal_fps * \ - (ss + mm * 60 + hh * 3600) + frame_index = ff + self.__nominal_fps * (ss + mm * 60 + hh * 3600) else: drop_per_min = self.__nominal_fps / 30 * 2 # 检查是否有DF下不合法的帧号 if mm % 10 != 0 and ss == 0 and ff in (0, drop_per_min - 1): raise DFTTTimecodeValueError( - f'This timecode: [{timecode_value}] is illegal under given params, check your input!') + f"This timecode: [{timecode_value}] is illegal under given params, check your input!" + ) else: total_minutes = 60 * hh + mm - frame_index = (hh * 3600 + mm * 60 + ss) * self.__nominal_fps + ff - ( - self.__nominal_fps / 30) * 2 * ( - # 逢十分钟不丢帧 http://andrewduncan.net/timecodes/ - total_minutes - total_minutes // 10) + frame_index = ( + (hh * 3600 + mm * 60 + ss) * self.__nominal_fps + + ff + - (self.__nominal_fps / 30) + * 2 + * ( + # 逢十分钟不丢帧 http://andrewduncan.net/timecodes/ + total_minutes - total_minutes // 10 + ) + ) if self.__strict: # strict输入逻辑 - frame_index = frame_index % (self.__fps * 86400) if self.__drop_frame else frame_index % ( - self.__nominal_fps * 86400) # 对于DF时码来说,严格处理取真实FPS的模,对于NDF时码,则取名义FPS的模 - + frame_index = ( + frame_index % (self.__fps * 86400) + if self.__drop_frame + else frame_index % (self.__nominal_fps * 86400) + ) # 对于DF时码来说,严格处理取真实FPS的模,对于NDF时码,则取名义FPS的模 + if minus_flag: frame_index = -frame_index self.__precise_time = Fraction(frame_index) / Fraction(self.__fps) # 时间戳=帧号/帧率 - - def __init_srt(self, timecode_value: str,minus_flag:bool): - match = self._ensure_match(SRT_REGEX, timecode_value, 'srt') - hh, mm, ss, sub_sec = map(int, match.groups(default='0')) + + def __init_srt(self, timecode_value: str, minus_flag: bool): + match = self._ensure_match(SRT_REGEX, timecode_value, "srt") + hh, mm, ss, sub_sec = map(int, match.groups(default="0")) # 由于SRT格式本身不存在帧率,将为SRT赋予默认帧率和丢帧状态 - logger.info(f'SRT timecode framerate {self.__fps}, DF={self.__drop_frame} assigned') - + logger.info(f"SRT timecode framerate {self.__fps}, DF={self.__drop_frame} assigned") + self.__precise_time = Fraction(hh * 3600 + mm * 60 + ss + sub_sec / 1000) if minus_flag: self.__precise_time = -self.__precise_time - + self.__apply_strict() - - + def __init_dlp(self, timecode_value: str, minus_flag: bool): - match = self._ensure_match(DLP_REGEX, timecode_value, 'dlp') - hh, mm, ss, sub_sec = map(int, match.groups(default='0')) + match = self._ensure_match(DLP_REGEX, timecode_value, "dlp") + hh, mm, ss, sub_sec = map(int, match.groups(default="0")) # 由于DLP不存在帧率,将为DLP赋予默认帧率和丢帧状态 - logger.info(f'DLP timecode framerate {self.__fps}, DF={self.__drop_frame} assigned') + logger.info(f"DLP timecode framerate {self.__fps}, DF={self.__drop_frame} assigned") # dlp每秒共250个子帧 即4ms一个 # 详见https://interop-docs.cinepedia.com/Reference_Documents/CineCanvas(tm)_RevC.pdf 第17页 “TimeIn”部分 @@ -241,159 +263,203 @@ def __init_dlp(self, timecode_value: str, minus_flag: bool): self.__precise_time = -self.__precise_time self.__apply_strict() - - def __init_ffmpeg(self, timecode_value: str,minus_flag:bool): - match = self._ensure_match(FFMPEG_REGEX, timecode_value, 'ffmpeg') - hh, mm, ss, sub_sec = map(int, match.groups(default='0')) - self.__precise_time = Fraction(hh * 3600 + mm * 60 + ss + float(f'0.{sub_sec}')) + def __init_ffmpeg(self, timecode_value: str, minus_flag: bool): + match = self._ensure_match(FFMPEG_REGEX, timecode_value, "ffmpeg") + hh, mm, ss, sub_sec = map(int, match.groups(default="0")) + self.__precise_time = Fraction(hh * 3600 + mm * 60 + ss + float(f"0.{sub_sec}")) if minus_flag: self.__precise_time = -self.__precise_time - + self.__apply_strict() - def __init_fcpx(self, timecode_value: str,minus_flag:bool): - match = self._ensure_match(FCPX_REGEX, timecode_value, 'fcpx') - numerator, denominator = map(int, match.groups(default='0')) + def __init_fcpx(self, timecode_value: str, minus_flag: bool): + match = self._ensure_match(FCPX_REGEX, timecode_value, "fcpx") + numerator, denominator = map(int, match.groups(default="0")) self.__precise_time = Fraction(numerator, denominator) if minus_flag: self.__precise_time = -self.__precise_time - + self.__apply_strict() - - def __init_frame(self, timecode_value: str,minus_flag:bool): - match = self._ensure_match(FRAME_REGEX, timecode_value, 'frame') + + def __init_frame(self, timecode_value: str, minus_flag: bool): + match = self._ensure_match(FRAME_REGEX, timecode_value, "frame") temp_frame_index = int(match.group(1)) - if self.__strict: # 严格模式,对于丢帧时码而言 用实际FPS运算,对于不丢帧时码而言,使用名义FPS运算 - temp_frame_index = temp_frame_index % ( - self.__fps * 86400) if self.__drop_frame else temp_frame_index % ( - self.__nominal_fps * 86400) + if ( + self.__strict + ): # 严格模式,对于丢帧时码而言 用实际FPS运算,对于不丢帧时码而言,使用名义FPS运算 + temp_frame_index = ( + temp_frame_index % (self.__fps * 86400) + if self.__drop_frame + else temp_frame_index % (self.__nominal_fps * 86400) + ) else: pass - self.__precise_time = Fraction(temp_frame_index) / Fraction(self.__fps) # 转换为内部精准时间戳 - - def __init_time(self, timecode_value: str,minus_flag:bool): - match = self._ensure_match(TIME_REGEX, timecode_value, 'time') + self.__precise_time = Fraction(temp_frame_index) / Fraction( + self.__fps + ) # 转换为内部精准时间戳 + + def __init_time(self, timecode_value: str, minus_flag: bool): + match = self._ensure_match(TIME_REGEX, timecode_value, "time") self.__precise_time = Fraction(match.group(1)) # 内部时间戳直接等于输入值 - + self.__apply_strict() - - def __init_common(self, timecode_type: TimecodeType, fps: float, drop_frame: bool, strict: bool): + + def __init_common( + self, timecode_type: TimecodeType, fps: float, drop_frame: bool, strict: bool + ): self.__type: TimecodeType = timecode_type self.__fps = fps self.__nominal_fps = ceil(fps) self.__drop_frame = self.__validate_drop_frame(drop_frame, fps) self.__strict = strict - + @singledispatchmethod - def __init__(self, timecode_value, timecode_type='auto', fps=24.0, drop_frame=False, strict=True): # 构造函数 + def __init__( + self, timecode_value, timecode_type="auto", fps=24.0, drop_frame=False, strict=True + ): # 构造函数 raise DFTTTimecodeTypeError( f"Unsupported timecode value type: {type(timecode_value).__name__}" ) @__init__.register # 若传入的TC值为字符串,则调用此函数 - def _(self, timecode_value: str, timecode_type:TimecodeType='auto', fps=24.0, drop_frame=False, strict=True): + def _( + self, + timecode_value: str, + timecode_type: TimecodeType = "auto", + fps=24.0, + drop_frame=False, + strict=True, + ): # if timecode_value[0] == '-': # 判断首位是否为负,并为flag赋值 # minus_flag = True # else: # minus_flag = False - minus_flag= timecode_value.startswith('-') + minus_flag = timecode_value.startswith("-") self.__fps = fps # 读入帧率取整为名义帧率便于后续计算(包括判断时码是否合法,DF/NDF逻辑等) 用进一法是因为要判断ff值是否大于fps-1 self.__nominal_fps = ceil(fps) self.__drop_frame = self.__validate_drop_frame(drop_frame, fps) self.__strict = strict - - timecode_type= timecode_type if timecode_type != 'auto' else self.__detect_timecode_type(timecode_value) - + + timecode_type = ( + timecode_type + if timecode_type != "auto" + else self.__detect_timecode_type(timecode_value) + ) + self.__type: TimecodeType = timecode_type - timecode_type_handler_map={ - 'smpte':self.__init_smpte, - 'srt':self.__init_srt, - 'dlp':self.__init_dlp, - 'ffmpeg':self.__init_ffmpeg, - 'fcpx':self.__init_fcpx, - 'frame':self.__init_frame, - 'time':self.__init_time + timecode_type_handler_map = { + "smpte": self.__init_smpte, + "srt": self.__init_srt, + "dlp": self.__init_dlp, + "ffmpeg": self.__init_ffmpeg, + "fcpx": self.__init_fcpx, + "frame": self.__init_frame, + "time": self.__init_time, } - init_func=timecode_type_handler_map.get(timecode_type) + init_func = timecode_type_handler_map.get(timecode_type) if not init_func: - raise DFTTTimecodeTypeError(f'Unknown timecode type: [{timecode_type}]') - init_func(timecode_value,minus_flag) - - instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' + raise DFTTTimecodeTypeError(f"Unknown timecode type: [{timecode_type}]") + init_func(timecode_value, minus_flag) + + instance_success_log = f"value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}" # logging.debug(instance_success_log) logger.debug(instance_success_log) @__init__.register # 输入为Fraction类分数,此时认为输入是时间戳,若不是,则会报错 - def _(self, timecode_value: Fraction, timecode_type='time', fps=24.0, drop_frame=False, strict=True): - if timecode_type in ('time', 'auto'): - self.__init_common(timecode_type,fps,drop_frame,strict) + def _( + self, + timecode_value: Fraction, + timecode_type="time", + fps=24.0, + drop_frame=False, + strict=True, + ): + if timecode_type in ("time", "auto"): + self.__init_common(timecode_type, fps, drop_frame, strict) self.__precise_time = timecode_value # 内部时间戳直接等于输入值 self.__apply_strict() else: raise DFTTTimecodeTypeError( - f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' + f"Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input." + ) + instance_success_log = f"value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}" logger.debug(instance_success_log) @__init__.register - def _(self, timecode_value: int, timecode_type='frame', fps=24.0, drop_frame=False, strict=True): - if timecode_type in ('frame', 'auto'): - self.__init_common(timecode_type,fps,drop_frame,strict) + def _( + self, timecode_value: int, timecode_type="frame", fps=24.0, drop_frame=False, strict=True + ): + if timecode_type in ("frame", "auto"): + self.__init_common(timecode_type, fps, drop_frame, strict) temp_frame_index = timecode_value if self.__strict: - temp_frame_index = temp_frame_index % ( - self.__fps * 86400) if self.__drop_frame else temp_frame_index % ( - self.__nominal_fps * 86400) + temp_frame_index = ( + temp_frame_index % (self.__fps * 86400) + if self.__drop_frame + else temp_frame_index % (self.__nominal_fps * 86400) + ) self.__precise_time = Fraction(temp_frame_index / self.__fps) - elif timecode_type == 'time': - self.__init_common(timecode_type,fps,drop_frame,strict) + elif timecode_type == "time": + self.__init_common(timecode_type, fps, drop_frame, strict) self.__precise_time = Fraction(timecode_value) # 内部时间戳直接等于输入值 self.__apply_strict() else: raise DFTTTimecodeTypeError( - f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' + f"Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input." + ) + instance_success_log = f"value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}" logger.debug(instance_success_log) @__init__.register - def _(self, timecode_value: float, timecode_type='time', fps=24.0, drop_frame=False, strict=True): - if timecode_type in ('time', 'auto'): - self.__init_common(timecode_type,fps,drop_frame,strict) + def _( + self, timecode_value: float, timecode_type="time", fps=24.0, drop_frame=False, strict=True + ): + if timecode_type in ("time", "auto"): + self.__init_common(timecode_type, fps, drop_frame, strict) self.__precise_time = Fraction(timecode_value) # 内部时间戳直接等于输入值 self.__apply_strict() else: raise DFTTTimecodeTypeError( - f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' + f"Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input." + ) + instance_success_log = f"value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}" logger.debug(instance_success_log) @__init__.register - def _(self, timecode_value: tuple, timecode_type='time', fps=24.0, drop_frame=False, strict=True): - if timecode_type in ('time', 'auto'): - self.__init_common(timecode_type,fps,drop_frame,strict) + def _( + self, timecode_value: tuple, timecode_type="time", fps=24.0, drop_frame=False, strict=True + ): + if timecode_type in ("time", "auto"): + self.__init_common(timecode_type, fps, drop_frame, strict) self.__precise_time = Fraction( - int(timecode_value[0]), int(timecode_value[1])) # 将tuple输入视为分数 + int(timecode_value[0]), int(timecode_value[1]) + ) # 将tuple输入视为分数 self.__apply_strict() else: raise DFTTTimecodeTypeError( - f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' + f"Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input." + ) + instance_success_log = f"value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}" logger.debug(instance_success_log) @__init__.register - def _(self, timecode_value: list, timecode_type='time', fps=24.0, drop_frame=False, strict=True): - if timecode_type in ('time', 'auto'): - self.__init_common(timecode_type,fps,drop_frame,strict) + def _( + self, timecode_value: list, timecode_type="time", fps=24.0, drop_frame=False, strict=True + ): + if timecode_type in ("time", "auto"): + self.__init_common(timecode_type, fps, drop_frame, strict) self.__precise_time = Fraction( - int(timecode_value[0]), int(timecode_value[1])) # 将list输入视为分数 + int(timecode_value[0]), int(timecode_value[1]) + ) # 将list输入视为分数 self.__apply_strict() else: raise DFTTTimecodeTypeError( - f'Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input.') - instance_success_log = f'value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}' + f"Timecode type [{timecode_type}] DONOT match input value [{timecode_value}]! Check input." + ) + instance_success_log = f"value type {type(timecode_value)} Timecode instance: type={self.__type}, fps={self.__fps}, dropframe={self.__drop_frame}, strict={self.__strict}" logger.debug(instance_success_log) @property @@ -504,7 +570,7 @@ def precise_timestamp(self) -> Fraction: Fraction(1, 1) """ return self.__precise_time - + @property def smpte(self) -> str: """Get the SMPTE timecode string representation. @@ -518,7 +584,7 @@ def smpte(self) -> str: '01:00:00:00' """ return self._convert_to_output_smpte() - + @property def srt(self) -> str: """Get the SRT timecode string representation. @@ -532,7 +598,7 @@ def srt(self) -> str: '01:00:00,000' """ return self._convert_to_output_srt() - + @property def dlp(self) -> str: """Get the DLP timecode string representation. @@ -546,7 +612,7 @@ def dlp(self) -> str: '01:00:00:000' """ return self._convert_to_output_dlp() - + @property def ffmpeg(self) -> str: """Get the FFmpeg timecode string representation. @@ -560,7 +626,7 @@ def ffmpeg(self) -> str: '01:00:00.00' """ return self._convert_to_output_ffmpeg() - + @property def fcpx(self) -> str: """Get the Final Cut Pro X timecode string representation. @@ -574,7 +640,7 @@ def fcpx(self) -> str: '86400/24s' """ return self._convert_to_output_fcpx() - + @property def frame(self) -> int: """Get the frame count string representation. @@ -588,7 +654,7 @@ def frame(self) -> int: 86400 """ return self._convert_to_output_frame() - + @property def time(self) -> float: """Get the timestamp string representation in seconds. @@ -618,115 +684,121 @@ def _convert_to_output_smpte(self, output_part=0) -> str: df_framecount_10min = self.__nominal_fps * 600 - 9 * drop_per_min d, m = divmod(frame_index, df_framecount_10min) - drop_frame_frame_number = frame_index + drop_per_min * 9 * d + drop_per_min * ( - # 剩余小于十分钟部分计算丢了多少帧,补偿 - ((m - drop_per_min) // (self.__nominal_fps * 60 - drop_per_min)) if m > 2 else 0) + drop_frame_frame_number = ( + frame_index + + drop_per_min * 9 * d + + drop_per_min + * ( + # 剩余小于十分钟部分计算丢了多少帧,补偿 + ((m - drop_per_min) // (self.__nominal_fps * 60 - drop_per_min)) if m > 2 else 0 + ) + ) _nominal_framecount = int(drop_frame_frame_number) def _convert_framecount_to_smpte_parts(frame_count: int, fps: int) -> tuple: - hour, r_1 = divmod(frame_count, 60*60*fps) - minute, r_2 = divmod(r_1, 60*fps) + hour, r_1 = divmod(frame_count, 60 * 60 * fps) + minute, r_2 = divmod(r_1, 60 * fps) second, frame = divmod(r_2, fps) return int(hour), int(minute), int(second), round(frame) output_hh, output_mm, output_ss, output_ff = _convert_framecount_to_smpte_parts( - _nominal_framecount, self.__nominal_fps) + _nominal_framecount, self.__nominal_fps + ) - output_ff_format = '02d' if self.__fps < 100 else '03d' - output_minus_flag = '' if not minus_flag else '-' + output_ff_format = "02d" if self.__fps < 100 else "03d" + output_minus_flag = "" if not minus_flag else "-" output_strs = ( - f'{output_minus_flag}{output_hh:02d}', - f'{output_mm:02d}', - f'{output_ss:02d}', - f'{output_ff:{output_ff_format}}') + f"{output_minus_flag}{output_hh:02d}", + f"{output_mm:02d}", + f"{output_ss:02d}", + f"{output_ff:{output_ff_format}}", + ) if output_part > len(output_strs): - logger.warning( - 'No such part, will return the last part of timecode') + logger.warning("No such part, will return the last part of timecode") return output_strs[-1] # 输出完整时码字符串 if output_part == 0: - main_part = ':'.join(output_strs[:3]) + main_part = ":".join(output_strs[:3]) # 丢帧时码的帧号前应为分号 - separator = ';' if self.__drop_frame else ':' - output_str = f'{main_part}{separator}{output_strs[3]}' + separator = ";" if self.__drop_frame else ":" + output_str = f"{main_part}{separator}{output_strs[3]}" return output_str elif 1 <= output_part <= len(output_strs): - return output_strs[output_part-1] + return output_strs[output_part - 1] else: - raise IndexError( - 'Negtive output_part is not allowed') + raise IndexError("Negtive output_part is not allowed") - def _convert_precise_time_to_parts(self, sub_sec_multiplier: int, frame_seperator: str, sub_sec_format: str) -> tuple[str, str, str, str, str]: + def _convert_precise_time_to_parts( + self, sub_sec_multiplier: int, frame_seperator: str, sub_sec_format: str + ) -> tuple[str, str, str, str, str]: minus_flag: bool = self.__precise_time < 0 temp_precise_time = abs(self.__precise_time) - _hh, r_1 = divmod(temp_precise_time, 60*60) + _hh, r_1 = divmod(temp_precise_time, 60 * 60) _mm, r_2 = divmod(r_1, 60) _ss, r_3 = divmod(r_2, 1) - _sub_sec = round(r_3*sub_sec_multiplier) - + _sub_sec = round(r_3 * sub_sec_multiplier) + # Handle sub-second overflow if _sub_sec >= sub_sec_multiplier: _ss += _sub_sec // sub_sec_multiplier _sub_sec = _sub_sec % sub_sec_multiplier - + # Handle seconds overflow if _ss >= 60: _mm += _ss // 60 _ss = _ss % 60 - + # Handle minutes overflow if _mm >= 60: _hh += _mm // 60 _mm = _mm % 60 - - output_minus_flag = '' if not minus_flag else '-' - output_hh = f'{output_minus_flag}{int(_hh):02d}' - outpur_mm = f'{int(_mm):02d}' - output_ss = f'{int(_ss):02d}' - output_ff = f'{_sub_sec:{sub_sec_format}}' - output_full_str = f'{output_hh}:{outpur_mm}:{output_ss}{frame_seperator}{output_ff}' + output_minus_flag = "" if not minus_flag else "-" + output_hh = f"{output_minus_flag}{int(_hh):02d}" + outpur_mm = f"{int(_mm):02d}" + output_ss = f"{int(_ss):02d}" + output_ff = f"{_sub_sec:{sub_sec_format}}" + + output_full_str = f"{output_hh}:{outpur_mm}:{output_ss}{frame_seperator}{output_ff}" return output_full_str, output_hh, outpur_mm, output_ss, output_ff def _convert_to_output_srt(self, output_part=0) -> str: - output_strs = self._convert_precise_time_to_parts(sub_sec_multiplier=1000, - frame_seperator=',', - sub_sec_format='03d') + output_strs = self._convert_precise_time_to_parts( + sub_sec_multiplier=1000, frame_seperator=",", sub_sec_format="03d" + ) if output_part > 4: - logger.warning( - 'No such part, will return the last part of timecode') + logger.warning("No such part, will return the last part of timecode") return output_strs[-1] return output_strs[output_part] + ## def _convert_to_output_dlp(self, output_part=0) -> str: - output_strs = self._convert_precise_time_to_parts(sub_sec_multiplier=250, - frame_seperator=':', - sub_sec_format='03d') + output_strs = self._convert_precise_time_to_parts( + sub_sec_multiplier=250, frame_seperator=":", sub_sec_format="03d" + ) if output_part > 4: - logger.warning( - 'No such part, will return the last part of timecode') + logger.warning("No such part, will return the last part of timecode") return output_strs[-1] return output_strs[output_part] def _convert_to_output_ffmpeg(self, output_part=0) -> str: - output_strs = self._convert_precise_time_to_parts(sub_sec_multiplier=100, - frame_seperator='.', - sub_sec_format='02d') + output_strs = self._convert_precise_time_to_parts( + sub_sec_multiplier=100, frame_seperator=".", sub_sec_format="02d" + ) if output_part > 4: - logger.warning( - 'No such part, will return the last part of timecode') + logger.warning("No such part, will return the last part of timecode") return output_strs[-1] return output_strs[output_part] @@ -735,29 +807,26 @@ def _convert_to_output_fcpx(self, output_part=0) -> str: if output_part == 0: pass else: - logger.warning( - '_convert_to_output_fcpx: This timecode type has only one part.') + logger.warning("_convert_to_output_fcpx: This timecode type has only one part.") if float(self.__precise_time).is_integer(): - return f'{int(self.__precise_time)}s' + return f"{int(self.__precise_time)}s" else: - return f'{self.__precise_time.numerator}/{self.__precise_time.denominator}s' + return f"{self.__precise_time.numerator}/{self.__precise_time.denominator}s" def _convert_to_output_frame(self, output_part=0) -> int: if output_part != 0: - logger.warning( - 'This timecode type [frame] has only one part.') + logger.warning("This timecode type [frame] has only one part.") return round(self.__precise_time * self.__fps) def _convert_to_output_time(self, output_part=0) -> float: if output_part == 0: pass else: - logger.warning( - 'This timecode type [time] has only one part.') + logger.warning("This timecode type [time] has only one part.") output_time = round(float(self.__precise_time), 5) return output_time - def timecode_output(self, dest_type: TimecodeType = 'auto', output_part: int = 0) -> str: + def timecode_output(self, dest_type: TimecodeType = "auto", output_part: int = 0) -> str: """Convert timecode to specified format and return as string. Args: @@ -790,13 +859,14 @@ def timecode_output(self, dest_type: TimecodeType = 'auto', output_part: int = 0 - Falls back to SMPTE format if dest_type is invalid """ # Determine which format to use - format_type = self.__type if dest_type == 'auto' else dest_type - method_name = f'_convert_to_output_{format_type}' + format_type = self.__type if dest_type == "auto" else dest_type + method_name = f"_convert_to_output_{format_type}" # Check if the conversion method exists if not hasattr(self, method_name): logger.warning( - f'CANNOT find conversion method for type [{format_type}], will return SMPTE type') + f"CANNOT find conversion method for type [{format_type}], will return SMPTE type" + ) return self._convert_to_output_smpte(output_part) # Call the conversion method @@ -806,11 +876,10 @@ def timecode_output(self, dest_type: TimecodeType = 'auto', output_part: int = 0 except AttributeError as e: # Defensive fallback: if the conversion method itself is missing an attribute # (should not happen given the hasattr guard above), fall back to SMPTE. - logger.warning( - f'Conversion to [{format_type}] failed ({e}); falling back to SMPTE.') + logger.warning(f"Conversion to [{format_type}] failed ({e}); falling back to SMPTE.") return self._convert_to_output_smpte(output_part) - def set_fps(self, dest_fps: float, rounding: bool = True) -> 'DfttTimecode': + def set_fps(self, dest_fps: float, rounding: bool = True) -> "DfttTimecode": """Change the frame rate of the timecode. Args: @@ -836,13 +905,12 @@ def set_fps(self, dest_fps: float, rounding: bool = True) -> 'DfttTimecode': self.__fps = dest_fps self.__nominal_fps = ceil(self.__fps) if rounding: - self.__precise_time = Fraction(round( - self.__precise_time * self.__fps) / self.__fps) + self.__precise_time = Fraction(round(self.__precise_time * self.__fps) / self.__fps) else: pass return self - def set_type(self, dest_type: TimecodeType = 'smpte', rounding: bool = True) -> 'DfttTimecode': + def set_type(self, dest_type: TimecodeType = "smpte", rounding: bool = True) -> "DfttTimecode": """Change the internal timecode format type. Args: @@ -868,20 +936,21 @@ def set_type(self, dest_type: TimecodeType = 'smpte', rounding: bool = True) -> - Rounding adjusts precision to match format (e.g., SRT has millisecond precision) - Invalid types are ignored with a warning """ - if dest_type in ('smpte', 'srt', 'dlp', 'ffmpeg', 'fcpx', 'frame', 'time'): + if dest_type in ("smpte", "srt", "dlp", "ffmpeg", "fcpx", "frame", "time"): self.__type = dest_type else: - logger.warning(f'No such type [{dest_type}], will remain current type.') + logger.warning(f"No such type [{dest_type}], will remain current type.") if rounding: temp_str = self.timecode_output(dest_type) temp_timecode_object = DfttTimecode( - temp_str, dest_type, self.__fps, self.__drop_frame, self.__strict) + temp_str, dest_type, self.__fps, self.__drop_frame, self.__strict + ) self.__precise_time = temp_timecode_object.__precise_time else: pass return self - def set_strict(self, strict: bool = True) -> 'DfttTimecode': + def set_strict(self, strict: bool = True) -> "DfttTimecode": """Change the 24-hour strict mode setting. Args: @@ -905,8 +974,9 @@ def set_strict(self, strict: bool = True) -> 'DfttTimecode': if strict == self.__strict: pass else: - temp_timecode_object = DfttTimecode(self.__precise_time, 'time', self.__fps, self.__drop_frame, - strict) + temp_timecode_object = DfttTimecode( + self.__precise_time, "time", self.__fps, self.__drop_frame, strict + ) self.__precise_time = temp_timecode_object.__precise_time self.__strict = strict return self @@ -934,10 +1004,10 @@ def get_audio_sample_count(self, sample_rate: int) -> int: Note: Uses floor division to ensure sample count doesn't exceed the timecode position. """ - numerator,denominator=self.__precise_time.as_integer_ratio() - return floor(numerator * sample_rate/denominator) + numerator, denominator = self.__precise_time.as_integer_ratio() + return floor(numerator * sample_rate / denominator) - def move_frame(self, frames: int) -> 'DfttTimecode': + def move_frame(self, frames: int) -> "DfttTimecode": """Move the timecode by a certain number of frames. Args: frames: Number of frames to move. Positive to move forward, negative to move backward. @@ -945,27 +1015,27 @@ def move_frame(self, frames: int) -> 'DfttTimecode': DfttTimecode: Self reference for method chaining """ if not isinstance(frames, int): - raise DFTTTimecodeOperatorError('Frames parameter must be an integer.') + raise DFTTTimecodeOperatorError("Frames parameter must be an integer.") new_precise_time = self.__precise_time + Fraction(frames) / Fraction(self.__fps) - + self.__precise_time = new_precise_time self.__apply_strict() return self - - def move_time(self, seconds: Union[float, Fraction,int]) -> 'DfttTimecode': + + def move_time(self, seconds: Union[float, Fraction, int]) -> "DfttTimecode": """Move the timecode by a certain number of seconds. Args: seconds: Number of seconds to move. Positive to move forward, negative to move backward. Returns: DfttTimecode: Self reference for method chaining """ - if not isinstance(seconds, (float, Fraction,int)): - raise DFTTTimecodeOperatorError('Seconds parameter must be a float, int, or Fraction.') + if not isinstance(seconds, (float, Fraction, int)): + raise DFTTTimecodeOperatorError("Seconds parameter must be a float, int, or Fraction.") new_precise_time = self.__precise_time + Fraction(seconds) - + self.__precise_time = new_precise_time self.__apply_strict() @@ -982,9 +1052,9 @@ def __repr__(self) -> str: >>> repr(tc) '(Timecode:01:00:00:00, Type:smpte,FPS:24.00 NDF, Strict)' """ - drop_frame_flag = 'DF' if self.__drop_frame else 'NDF' - strict_flag = 'Strict' if self.__strict else 'Non-Strict' - return f'(Timecode:{self.timecode_output(self.__type)}, Type:{self.__type},FPS:{float(self.__fps):.02f} {drop_frame_flag}, {strict_flag})' + drop_frame_flag = "DF" if self.__drop_frame else "NDF" + strict_flag = "Strict" if self.__strict else "Non-Strict" + return f"(Timecode:{self.timecode_output(self.__type)}, Type:{self.__type},FPS:{float(self.__fps):.02f} {drop_frame_flag}, {strict_flag})" def __str__(self) -> str: """Return timecode as formatted string in current type. @@ -999,7 +1069,7 @@ def __str__(self) -> str: """ return self.timecode_output() - def __add__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTimecode': + def __add__(self, other: Union["DfttTimecode", int, float, Fraction]) -> "DfttTimecode": """Add timecode with another timecode, frame count, or timestamp. Args: @@ -1040,7 +1110,8 @@ def __add__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi self.__strict = self.__strict or other.__strict else: # 帧率不同不允许相加,报错 raise DFTTTimecodeOperatorError( - 'Timecode addition requires exact same FPS and drop_frame status.') + "Timecode addition requires exact same FPS and drop_frame status." + ) elif isinstance(other, int): # 帧 temp_sum = self.__precise_time + (other / self.__fps) elif isinstance(other, float): # 时间 @@ -1048,13 +1119,12 @@ def __add__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi elif isinstance(other, Fraction): # 时间 temp_sum = self.__precise_time + other else: - raise DFTTTimecodeOperatorError(f'Undefined addition with [{type(other).__name__}].') - temp_object = DfttTimecode( - temp_sum, 'time', self.__fps, self.__drop_frame, self.__strict) + raise DFTTTimecodeOperatorError(f"Undefined addition with [{type(other).__name__}].") + temp_object = DfttTimecode(temp_sum, "time", self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) return temp_object - def __radd__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': + def __radd__(self, other: Union[int, float, Fraction]) -> "DfttTimecode": """Reflected addition (called when left operand doesn't support +). Implements commutative property: other + timecode == timecode + other @@ -1073,7 +1143,7 @@ def __radd__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': """ return self.__add__(other) - def __sub__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTimecode': + def __sub__(self, other: Union["DfttTimecode", int, float, Fraction]) -> "DfttTimecode": """Subtract timecode, frame count, or timestamp from this timecode. Args: @@ -1109,7 +1179,8 @@ def __sub__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi self.__strict = self.__strict or other.__strict else: raise DFTTTimecodeOperatorError( - 'Timecode subtraction requires exact same FPS and drop_frame status.') + "Timecode subtraction requires exact same FPS and drop_frame status." + ) elif isinstance(other, int): # 帧 diff = self.__precise_time - other / self.__fps elif isinstance(other, float): # 时间 @@ -1117,9 +1188,8 @@ def __sub__(self, other: Union['DfttTimecode', int, float, Fraction]) -> 'DfttTi elif isinstance(other, Fraction): # 时间 diff = self.__precise_time - other else: - raise DFTTTimecodeOperatorError(f'Undefined subtraction with [{type(other).__name__}].') - temp_object = DfttTimecode( - diff, 'time', self.__fps, self.__drop_frame, self.__strict) + raise DFTTTimecodeOperatorError(f"Undefined subtraction with [{type(other).__name__}].") + temp_object = DfttTimecode(diff, "time", self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) return temp_object @@ -1132,13 +1202,12 @@ def __rsub__(self, other): # 运算符重载,减法,同理,int是帧,fl elif isinstance(other, Fraction): # 时间 diff = other - self.__precise_time else: - raise DFTTTimecodeOperatorError(f'Undefined subtraction with [{type(other).__name__}].') - temp_object = DfttTimecode( - diff, 'time', self.__fps, self.__drop_frame, self.__strict) + raise DFTTTimecodeOperatorError(f"Undefined subtraction with [{type(other).__name__}].") + temp_object = DfttTimecode(diff, "time", self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) return temp_object - def __mul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': + def __mul__(self, other: Union[int, float, Fraction]) -> "DfttTimecode": """Multiply timecode by a numeric factor. Args: @@ -1165,8 +1234,7 @@ def __mul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': """ prod = self.__precise_time if isinstance(other, DfttTimecode): - raise DFTTTimecodeOperatorError( - 'Timecode CANNOT multiply with another Timecode.') + raise DFTTTimecodeOperatorError("Timecode CANNOT multiply with another Timecode.") elif isinstance(other, int): prod = self.__precise_time * other elif isinstance(other, float): @@ -1174,13 +1242,14 @@ def __mul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': elif isinstance(other, Fraction): prod = self.__precise_time * other else: - raise DFTTTimecodeOperatorError(f'Undefined multiplication with [{type(other).__name__}].') - temp_object = DfttTimecode( - prod, 'time', self.__fps, self.__drop_frame, self.__strict) + raise DFTTTimecodeOperatorError( + f"Undefined multiplication with [{type(other).__name__}]." + ) + temp_object = DfttTimecode(prod, "time", self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) return temp_object - def __rmul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': + def __rmul__(self, other: Union[int, float, Fraction]) -> "DfttTimecode": """Reflected multiplication (implements commutative property). Args: @@ -1197,7 +1266,7 @@ def __rmul__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': """ return self.__mul__(other) - def __truediv__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': + def __truediv__(self, other: Union[int, float, Fraction]) -> "DfttTimecode": """Divide timecode by a numeric factor. Args: @@ -1221,8 +1290,7 @@ def __truediv__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': """ quo_time = self.__precise_time # quo_time是商(时间戳) if isinstance(other, DfttTimecode): - raise DFTTTimecodeOperatorError( - 'Timecode CANNOT be divided by another Timecode.') + raise DFTTTimecodeOperatorError("Timecode CANNOT be divided by another Timecode.") elif isinstance(other, int): # timecode与数相除,得到结果是timecode quo_time = self.__precise_time / other elif isinstance(other, float): # timecode与数相除,得到结果是timecode @@ -1230,18 +1298,16 @@ def __truediv__(self, other: Union[int, float, Fraction]) -> 'DfttTimecode': elif isinstance(other, Fraction): # timecode与数相除,得到结果是timecode quo_time = self.__precise_time / other else: - raise DFTTTimecodeOperatorError(f'Undefined division with [{type(other).__name__}].') - temp_object = DfttTimecode( - quo_time, 'time', self.__fps, self.__drop_frame, self.__strict) + raise DFTTTimecodeOperatorError(f"Undefined division with [{type(other).__name__}].") + temp_object = DfttTimecode(quo_time, "time", self.__fps, self.__drop_frame, self.__strict) temp_object.set_type(self.type, rounding=False) return temp_object def __rtruediv__(self, other): if isinstance(other, (int, float, Fraction)): - raise DFTTTimecodeOperatorError( - 'Number CANNOT be divided by a Timecode.') + raise DFTTTimecodeOperatorError("Number CANNOT be divided by a Timecode.") else: - raise DFTTTimecodeOperatorError(f'Undefined division with [{type(other).__name__}].') + raise DFTTTimecodeOperatorError(f"Undefined division with [{type(other).__name__}].") def __eq__(self, other: object) -> bool: """Check equality with another timecode or numeric value. @@ -1275,15 +1341,19 @@ def __eq__(self, other: object) -> bool: raise DFTTTimecodeOperatorError else: return round(self.__precise_time, 5) == round(other.__precise_time, 5) - elif isinstance(other, int): # 与int比较 默认int为帧号 比较当前timecode对象的帧号是否与其一致 - return int(self.timecode_output('frame')) == other - elif isinstance(other, float): # 与float比较 默认float为时间戳 比较当前timecode对象的时间戳是否与其一致 精确到5位小数 + elif isinstance( + other, int + ): # 与int比较 默认int为帧号 比较当前timecode对象的帧号是否与其一致 + return int(self.timecode_output("frame")) == other + elif isinstance( + other, float + ): # 与float比较 默认float为时间戳 比较当前timecode对象的时间戳是否与其一致 精确到5位小数 return float(round(self.__precise_time, 5)) == round(other, 5) # 与Fraction比较 默认Fraction为时间戳 比较当前timecode对象的时间戳是否与其一致 精确到5位小数 elif isinstance(other, Fraction): return round(self.__precise_time, 5) == round(other, 5) else: - raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') + raise DFTTTimecodeTypeError(f"CANNOT compare with data type [{type(other).__name__}].") def __ne__(self, other: object) -> bool: """Check inequality with another timecode or numeric value. @@ -1302,7 +1372,7 @@ def __ne__(self, other: object) -> bool: """ return not self.__eq__(other) - def __lt__(self, other: Union['DfttTimecode', int, float, Fraction]) -> bool: + def __lt__(self, other: Union["DfttTimecode", int, float, Fraction]) -> bool: """Check if this timecode is less than another value. Args: @@ -1331,13 +1401,13 @@ def __lt__(self, other: Union['DfttTimecode', int, float, Fraction]) -> bool: else: return round(self.__precise_time, 5) < round(other.__precise_time, 5) elif isinstance(other, int): - return int(self.timecode_output('frame')) < other + return int(self.timecode_output("frame")) < other elif isinstance(other, float): return float(round(self.__precise_time, 5)) < round(other, 5) elif isinstance(other, Fraction): return round(self.__precise_time, 5) < round(other, 5) else: - raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') + raise DFTTTimecodeTypeError(f"CANNOT compare with data type [{type(other).__name__}].") def __le__(self, other): # 详见__eq__ if isinstance(other, DfttTimecode): @@ -1346,13 +1416,13 @@ def __le__(self, other): # 详见__eq__ else: return round(self.__precise_time, 5) <= round(other.__precise_time, 5) elif isinstance(other, int): - return int(self.timecode_output('frame')) <= other + return int(self.timecode_output("frame")) <= other elif isinstance(other, float): return float(round(self.__precise_time, 5)) <= round(other, 5) elif isinstance(other, Fraction): return round(self.__precise_time, 5) <= round(other, 5) else: - raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') + raise DFTTTimecodeTypeError(f"CANNOT compare with data type [{type(other).__name__}].") def __gt__(self, other): # 详见__eq__ if isinstance(other, DfttTimecode): @@ -1361,13 +1431,13 @@ def __gt__(self, other): # 详见__eq__ else: return round(self.__precise_time, 5) > round(other.__precise_time, 5) elif isinstance(other, int): - return int(self.timecode_output('frame')) > other + return int(self.timecode_output("frame")) > other elif isinstance(other, float): return float(round(self.__precise_time, 5)) > round(other, 5) elif isinstance(other, Fraction): return round(self.__precise_time, 5) > round(other, 5) else: - raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') + raise DFTTTimecodeTypeError(f"CANNOT compare with data type [{type(other).__name__}].") def __ge__(self, other): # 详见__eq__ if isinstance(other, DfttTimecode): @@ -1376,15 +1446,15 @@ def __ge__(self, other): # 详见__eq__ else: return round(self.__precise_time, 5) >= round(other.__precise_time, 5) elif isinstance(other, int): - return int(self.timecode_output('frame')) >= other + return int(self.timecode_output("frame")) >= other elif isinstance(other, float): return float(round(self.__precise_time, 5)) >= round(other, 5) elif isinstance(other, Fraction): return round(self.__precise_time, 5) >= round(other, 5) else: - raise DFTTTimecodeTypeError(f'CANNOT compare with data type [{type(other).__name__}].') + raise DFTTTimecodeTypeError(f"CANNOT compare with data type [{type(other).__name__}].") - def __neg__(self) -> 'DfttTimecode': + def __neg__(self) -> "DfttTimecode": """Return the negation of this timecode. Returns: @@ -1404,8 +1474,9 @@ def __neg__(self) -> 'DfttTimecode': >>> print(neg_tc_strict) 23:00:00:00 """ - temp_object = DfttTimecode(-self.__precise_time, 'time', - self.__fps, self.__drop_frame, self.__strict) + temp_object = DfttTimecode( + -self.__precise_time, "time", self.__fps, self.__drop_frame, self.__strict + ) temp_object.set_type(self.type, rounding=False) return temp_object diff --git a/dftt_timecode/core/dftt_timerange.py b/dftt_timecode/core/dftt_timerange.py index 549f94a..51f4751 100644 --- a/dftt_timecode/core/dftt_timerange.py +++ b/dftt_timecode/core/dftt_timerange.py @@ -218,9 +218,7 @@ def _init_from_timecodes(self, start_tc, end_tc): self.__precise_duration += self.TIME_24H_SECONDS if self.__precise_duration == 0: - raise DFTTTimeRangeValueError( - "Time range cannot be zero-length (start equals end)!" - ) + raise DFTTTimeRangeValueError("Time range cannot be zero-length (start equals end)!") self.__start_precise_time = start_precise @@ -392,9 +390,7 @@ def extend(self, extend_value: Union[int, float, DfttTimecode, str]) -> "DfttTim ) if new_duration == 0: - raise DFTTTimeRangeValueError( - "Cannot create zero-length timerange via extend" - ) + raise DFTTTimeRangeValueError("Cannot create zero-length timerange via extend") # Handle 24h constraint if self.__strict_24h and abs(new_duration) > self.TIME_24H_SECONDS: @@ -477,9 +473,7 @@ def reverse(self) -> "DfttTimeRange": - Duration magnitude is preserved - This is useful for working with timeranges that play backward """ - logger.debug( - f"Reversing timerange: forward={self.__forward} -> {not self.__forward}" - ) + logger.debug(f"Reversing timerange: forward={self.__forward} -> {not self.__forward}") return DfttTimeRange( start_precise_time=self.end_precise_time, precise_duration=self.__precise_duration, @@ -599,9 +593,7 @@ def separate(self, num_parts: int) -> List["DfttTimeRange"]: - Useful for splitting work into parallel chunks or creating segments """ if num_parts < 2: - raise DFTTTimeRangeValueError( - f"Cannot separate into {num_parts} parts, must be >= 2" - ) + raise DFTTTimeRangeValueError(f"Cannot separate into {num_parts} parts, must be >= 2") part_duration = self.__precise_duration / num_parts logger.debug( @@ -626,7 +618,11 @@ def separate(self, num_parts: int) -> List["DfttTimeRange"]: return parts # Operations with other timeranges - def contains(self, item: Union[DfttTimecode, 'DfttTimeRange', str, int, float], strict_forward: bool = False) -> bool: + def contains( + self, + item: Union[DfttTimecode, "DfttTimeRange", str, int, float], + strict_forward: bool = False, + ) -> bool: """Check if timerange contains another timerange or timecode. Args: @@ -674,9 +670,9 @@ def contains(self, item: Union[DfttTimecode, 'DfttTimeRange', str, int, float], item_start = item.start_precise_time item_end = item.end_precise_time - return self.contains( - DfttTimecode(float(item_start), fps=self.__fps) - ) and self.contains(DfttTimecode(float(item_end), fps=self.__fps)) + return self.contains(DfttTimecode(float(item_start), fps=self.__fps)) and self.contains( + DfttTimecode(float(item_end), fps=self.__fps) + ) else: try: tc = DfttTimecode(item, fps=self.__fps) @@ -882,9 +878,7 @@ def union(self, other: "DfttTimeRange") -> "DfttTimeRange": duration = end - start if self.__forward else start - end - logger.debug( - f"Union created: start={float(start):.3f}s, duration={float(duration):.3f}s" - ) + logger.debug(f"Union created: start={float(start):.3f}s, duration={float(duration):.3f}s") return DfttTimeRange( start_precise_time=start, @@ -948,9 +942,7 @@ def add(self, other: "DfttTimeRange") -> "DfttTimeRange": new_duration = self.__precise_duration - other.precise_duration if new_duration == 0: - raise DFTTTimeRangeValueError( - "Add operation resulted in zero-length timerange" - ) + raise DFTTTimeRangeValueError("Add operation resulted in zero-length timerange") logger.debug( f"Add timerange: same_direction={self.__forward == other.forward}, " @@ -1024,9 +1016,7 @@ def subtract(self, other: "DfttTimeRange") -> "DfttTimeRange": new_duration = self.__precise_duration + other.precise_duration if new_duration == 0: - raise DFTTTimeRangeValueError( - "Subtract operation resulted in zero-length timerange" - ) + raise DFTTTimeRangeValueError("Subtract operation resulted in zero-length timerange") logger.debug( f"Subtract timerange: same_direction={self.__forward == other.forward}, " diff --git a/pyproject.toml b/pyproject.toml index 8388fdd..998d2fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,13 @@ wheel-exclude = [".DS_Store", "**/.DS_Store"] addopts = "-v -s" testpaths = ["test"] +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ty.environment] +python-version = "3.11" + [dependency-groups] dev = [ "pytest>=8.3.4", diff --git a/test/test_dftt_timerange.py b/test/test_dftt_timerange.py index 2151154..576aad6 100644 --- a/test/test_dftt_timerange.py +++ b/test/test_dftt_timerange.py @@ -15,7 +15,7 @@ def test_init_with_timecodes(self): tr = DfttTimeRange(start, end) assert tr.fps == 24 - assert tr.forward == True + assert tr.forward assert tr.duration == 1.0 assert tr.framecount == 24 @@ -45,7 +45,7 @@ def test_init_reverse_direction(self): end = DfttTimecode('00:00:00:00', fps=24) tr = DfttTimeRange(start, end, forward=False) - assert tr.forward == False + assert not tr.forward assert tr.duration == 2.0 def test_init_fps_mismatch_error(self): @@ -73,7 +73,7 @@ def test_init_strict_24h(self): strict_24h=True ) - assert tr.strict_24h == True + assert tr.strict_24h assert tr.duration == 86400 def test_init_strict_24h_exceeded_error(self): @@ -100,8 +100,8 @@ def test_basic_properties(self): ) assert tr.fps == 25 - assert tr.forward == False - assert tr.strict_24h == False + assert not tr.forward + assert not tr.strict_24h assert tr.precise_duration == Fraction(5) assert tr.start_precise_time == Fraction(10) @@ -255,7 +255,7 @@ def test_reverse(self): assert reversed_tr.start_precise_time == Fraction(15) # original end assert reversed_tr.precise_duration == Fraction(5) - assert reversed_tr.forward == False + assert not reversed_tr.forward def test_retime_factor(self): """Test retime with factor""" @@ -325,8 +325,8 @@ def test_contains_timecode_forward(self): tc_inside = DfttTimecode(12.0, fps=24) tc_outside = DfttTimecode(16.0, fps=24) - assert tr.contains(tc_inside) == True - assert tr.contains(tc_outside) == False + assert tr.contains(tc_inside) + assert not tr.contains(tc_outside) def test_contains_timecode_reverse(self): """Test contains with timecode in reverse direction""" @@ -340,8 +340,8 @@ def test_contains_timecode_reverse(self): tc_inside = DfttTimecode(8.0, fps=24) # between 5 and 10 tc_outside = DfttTimecode(12.0, fps=24) - assert tr.contains(tc_inside) == True - assert tr.contains(tc_outside) == False + assert tr.contains(tc_inside) + assert not tr.contains(tc_outside) def test_contains_timerange(self): """Test contains with another timerange""" @@ -357,8 +357,8 @@ def test_contains_timerange(self): fps=24 ) - assert tr1.contains(tr2) == True - assert tr2.contains(tr1) == False + assert tr1.contains(tr2) + assert not tr2.contains(tr1) def test_contains_string_input(self): """Test contains with string input""" @@ -368,8 +368,8 @@ def test_contains_string_input(self): fps=24 ) - assert tr.contains('12.0s') == True - assert tr.contains('16.0s') == False + assert tr.contains('12.0s') + assert not tr.contains('16.0s') class TestDfttTimeRangeOperations: