diff --git a/README.md b/README.md index e3b0dd9..dac46aa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A desktop application for annotating Deep Brain Stimulation (DBS) clinical programming sessions. Built for clinicians and researchers working with DBS systems (Medtronic Percept and others). -**Version:** 0.3.0-beta +**Version:** derived from `clinical_dbs_annotator.__version__` **Author:** Lucia Poma (lucia.poma@wysscenter.ch) ## For End Users @@ -10,7 +10,7 @@ A desktop application for annotating Deep Brain Stimulation (DBS) clinical progr **No installation required.** Download the pre-built executable and run it directly: 1. Go to the `dist/` folder -2. Run `ClinicalDBSAnnot_v0_3_testing.exe` +2. Run the `ClinicalDBSAnnotator_*.exe` artifact for your platform/version 3. That's it — the application opens immediately The executable is self-contained and includes all dependencies. diff --git a/pyproject.toml b/pyproject.toml index fa46af1..806a110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "pytz>=2025.2", + "tzdata>=2025.3", "pandas>=2.1", "python-docx>=1.1", "docx2pdf>=0.1.8", diff --git a/scripts/build_macos.py b/scripts/build_macos.py index 2224ca1..c234d08 100644 --- a/scripts/build_macos.py +++ b/scripts/build_macos.py @@ -5,6 +5,7 @@ """ import argparse +import re import shutil import subprocess import sys @@ -18,9 +19,19 @@ SRC_DIR = PROJECT_ROOT / "src" APP_NAME = "ClinicalDBSAnnot" -VERSION = "v0.3_testing" PLATFORM = "macOS" +def _read_version() -> str: + init_path = SRC_DIR / "clinical_dbs_annotator" / "__init__.py" + text = init_path.read_text(encoding="utf-8") + m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']\s*$', text, flags=re.MULTILINE) + if not m: + raise RuntimeError(f"Could not determine version from {init_path}") + return m.group(1) + + +VERSION = _read_version() + def update_macos_logo(png_path: Path) -> bool: """Update logoneutral.png from the provided file and regenerate logoneutral.icns.""" @@ -80,7 +91,7 @@ def build_macos_app(*, console: bool, onefile: bool): f"--workpath={BUILD_DIR / 'pyinstaller'}", f"--specpath={BUILD_DIR / 'pyinstaller'}", f"--icon={icon_path}", - "--hidden-import=pytz", + "--hidden-import=tzdata", "--hidden-import=pandas", "--hidden-import=openpyxl", "--hidden-import=xlrd", diff --git a/scripts/build_windows.py b/scripts/build_windows.py index 74339a8..ecbf7fd 100644 --- a/scripts/build_windows.py +++ b/scripts/build_windows.py @@ -5,28 +5,37 @@ """ import argparse +import re import subprocess import sys from pathlib import Path -# Get project root directory PROJECT_ROOT = Path(__file__).parent.parent DIST_DIR = PROJECT_ROOT / "dist" BUILD_DIR = PROJECT_ROOT / "build" ICONS_DIR = PROJECT_ROOT / "icons" SRC_DIR = PROJECT_ROOT / "src" -APP_NAME = "ClinicalDBSAnnot" -VERSION = "v0.3_testing" +APP_NAME = "ClinicalDBSAnnotator" PLATFORM = "Windows" +def _read_version() -> str: + init_path = SRC_DIR / "clinical_dbs_annotator" / "__init__.py" + text = init_path.read_text(encoding="utf-8") + m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']\s*$', text, flags=re.MULTILINE) + if not m: + raise RuntimeError(f"Could not determine version from {init_path}") + return m.group(1) + + +VERSION = _read_version() + def build_windows_exe(*, console: bool, onefile: bool) -> bool: """Build Windows executable using PyInstaller.""" print(f"Building {APP_NAME} {VERSION} for Windows...") name = f"{APP_NAME}_{PLATFORM}_{VERSION.replace('.', '_')}" - # Use run.py as entrypoint entrypoint = PROJECT_ROOT / "run.py" styles_dir = PROJECT_ROOT / "styles" config_dir = SRC_DIR / "clinical_dbs_annotator" / "config" @@ -40,10 +49,7 @@ def build_windows_exe(*, console: bool, onefile: bool) -> bool: f"--distpath={DIST_DIR}", f"--workpath={BUILD_DIR / 'pyinstaller'}", f"--specpath={BUILD_DIR / 'pyinstaller'}", - # "--exclude-module=pythoncom", - # "--exclude-module=pywintypes", - # "--exclude-module=win32com", - "--hidden-import=pytz", + "--hidden-import=tzdata", "--hidden-import=pandas", "--hidden-import=openpyxl", "--hidden-import=xlrd", @@ -73,7 +79,6 @@ def build_windows_exe(*, console: bool, onefile: bool) -> bool: ] ) - # Run PyInstaller try: subprocess.run(cmd, check=True, cwd=PROJECT_ROOT) print("\n✓ Build successful!") diff --git a/scripts/build_windows_nuitka.py b/scripts/build_windows_nuitka.py index 1df0e2e..ac2b94a 100644 --- a/scripts/build_windows_nuitka.py +++ b/scripts/build_windows_nuitka.py @@ -14,6 +14,7 @@ """ import argparse +import re import sys from pathlib import Path @@ -26,11 +27,28 @@ STYLES_DIR = PROJECT_ROOT / "styles" CONFIG_DIR = SRC_DIR / "clinical_dbs_annotator" / "config" -# --- Build configuration ---------------------------------------------------- -NAME = "ClinicalDBSAnnot" +NAME = "ClinicalDBSAnnotator" ENTRYPOINT = PROJECT_ROOT / "run.py" -# Data files to include (src_path:dest_path) +def _read_version() -> str: + init_path = SRC_DIR / "clinical_dbs_annotator" / "__init__.py" + text = init_path.read_text(encoding="utf-8") + m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']\s*$', text, flags=re.MULTILINE) + if not m: + raise RuntimeError(f"Could not determine version from {init_path}") + return m.group(1) + + +def _base_version(version: str) -> str: + m = re.search(r"(\d+\.\d+\.\d+)", version) + if not m: + raise RuntimeError(f"Could not extract base version from {version!r}") + return m.group(1) + + +VERSION = _read_version() +BASE_VERSION = _base_version(VERSION) + DATA_FILES = [ (ICONS_DIR / "logoneutral.ico", "icons"), (ICONS_DIR / "logoneutral.png", "icons"), @@ -40,9 +58,8 @@ (CONFIG_DIR / "session_scales_presets.json", "config"), ] -# Hidden imports (modules that are imported dynamically) HIDDEN_IMPORTS = [ - "pytz", + "tzdata", "pandas", "openpyxl", "xlrd", @@ -58,20 +75,16 @@ "PIL.ImageFont", ] -# Qt plugins to include QT_PLUGINS = [ "platforms", "imageformats", ] -# --- Main build function -------------------------------------------------- def build_nuitka(console: bool = False, onefile: bool = False) -> None: """Build the executable with Nuitka.""" - # Ensure directories exist DIST_DIR.mkdir(exist_ok=True) BUILD_DIR.mkdir(exist_ok=True) - # Base command cmd = [ sys.executable, "-m", @@ -82,68 +95,55 @@ def build_nuitka(console: bool = False, onefile: bool = False) -> None: f"--output-filename={NAME}.exe", ] - # Console vs windowed if console: cmd.append("--console") else: cmd.append("--windows-disable-console") - # Onefile vs onedir if onefile: cmd.append("--onefile") # Note: --remove-output is not needed, Nuitka handles cleanup - # Include data files for src_path, dest_path in DATA_FILES: if src_path.exists(): cmd.append(f"--include-data-file={src_path}={dest_path}") else: print(f"Warning: Data file not found: {src_path}") - # Hidden imports for module in HIDDEN_IMPORTS: cmd.append(f"--include-module={module}") - # Include Qt plugins for plugin in QT_PLUGINS: cmd.append(f"--include-qt-plugin={plugin}") - # Include PySide6 completely cmd.append("--follow-imports") - # Optimization flags cmd.extend([ "--enable-plugin=pyside6", ]) - # Icon icon_path = ICONS_DIR / "logoneutral.ico" if icon_path.exists(): cmd.append(f"--windows-icon-from-ico={icon_path}") - # Windows metadata cmd.extend([ "--windows-company-name=Brain Modulation Lab", "--windows-product-name=Clinical DBS Annotator", - "--windows-file-version=0.3.0", - "--windows-product-version=0.3.0", + f"--windows-file-version={BASE_VERSION}", + f"--windows-product-version={BASE_VERSION}", ]) - # Entry point cmd.append(str(ENTRYPOINT)) - # Print command for debugging print("Nuitka command:") print(" ".join(f'"{arg}"' if " " in str(arg) else str(arg) for arg in cmd)) print("\nBuilding... (this may take several minutes)") - # Execute try: import subprocess subprocess.run(cmd, check=True, cwd=PROJECT_ROOT) print("\n✅ Build completed successfully!") - # Show output location if onefile: exe_path = DIST_DIR / f"{NAME}.exe" else: @@ -162,7 +162,6 @@ def build_nuitka(console: bool = False, onefile: bool = False) -> None: print("\n❌ Nuitka not found. Install it with: pip install nuitka") sys.exit(1) -# ------------------------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser(description="Build Windows executable with Nuitka") parser.add_argument("--console", action="store_true", diff --git a/scripts/create_installer.nsi b/scripts/create_installer.nsi index e30e59f..04c7724 100644 --- a/scripts/create_installer.nsi +++ b/scripts/create_installer.nsi @@ -5,9 +5,10 @@ ; Download from: https://nsis.sourceforge.io/ !define APP_NAME "Clinical DBS Annotator" -!define APP_VERSION "0.1.0" +; Derive version from the Python package (single source of truth). +!searchparse /file "..\src\clinical_dbs_annotator\__init__.py" '__version__ = "' APP_VERSION '"' !define APP_PUBLISHER "BML" -!define APP_EXE "ClinicalDBSAnnot_v0_1.exe" +!define APP_EXE "ClinicalDBSAnnot_${APP_VERSION}.exe" !define INSTALL_DIR "$PROGRAMFILES\${APP_PUBLISHER}\${APP_NAME}" ; Includes @@ -15,7 +16,7 @@ ; General settings Name "${APP_NAME}" -OutFile "..\dist\ClinicalDBSAnnot_Installer_v0.1.exe" +OutFile "..\dist\ClinicalDBSAnnot_Installer_${APP_VERSION}.exe" InstallDir "${INSTALL_DIR}" InstallDirRegKey HKCU "Software\${APP_PUBLISHER}\${APP_NAME}" "InstallDir" RequestExecutionLevel admin diff --git a/src/clinical_dbs_annotator/config.py b/src/clinical_dbs_annotator/config.py index 6e4a1a6..bb7bae1 100644 --- a/src/clinical_dbs_annotator/config.py +++ b/src/clinical_dbs_annotator/config.py @@ -5,10 +5,12 @@ throughout the application. """ +from __future__ import annotations -# Application metadata -APP_NAME = "BML Annotator for DBS clinical programming sessions" -APP_VERSION = "v0.3_testing" +from .version import get_version + +APP_NAME = "Clinical DBS Annotator" +APP_VERSION = get_version() ORGANIZATION_NAME = "BML" # File paths (relative to executable) @@ -56,6 +58,7 @@ TSV_COLUMNS = [ "date", "time", + "timezone", "block_id", "group_ID", "session_ID", @@ -77,7 +80,7 @@ ] # Timezone configuration -TIMEZONE = "US/Eastern" +TIMEZONE = "local" # Validation limits STIMULATION_LIMITS = { @@ -94,42 +97,84 @@ "step2": 0.5, } -# Clinical scales presets CLINICAL_SCALES_PRESETS: dict[str, list[str]] = { - "OCD": ["YBOCS", "YBOCS-o", "YBOCS-c", "MADRS"], - "MDD": ["MADRS"], - "PD": ["UPDRS", "PDQ"], - "ET": ["FTM"], + "OCD": [ + "Y-BOCS", # Yale–Brown Obsessive–Compulsive Scale + "Y-BOCS-o", "Y-BOCS-c", + "MADRS", # Montgomery–Åsberg Depression Rating Scale + "OCI-R", # Obsessive–Compulsive Inventory – Revised + ], + "MDD": [ + "MADRS", # Montgomery–Åsberg Depression Rating Scale + "HAM-D", # Hamilton Depression Rating Scale + "BDI-II", # Beck Depression Inventory – Second Edition + ], + "PD": [ + "MDS-UPDRS", # Movement Disorder Society – Unified Parkinson’s Disease Rating Scale + "UPDRS-III", # Unified Parkinson’s Disease Rating Scale part III + "PDQ-39", # Parkinson’s Disease Questionnaire (39-item) + "UDysRS", # Unified Dyskinesia Rating Scale + ], + "ET": [ + "FTM-TRS", # Fahn–Tolosa–Marin Tremor Rating Scale + "TETRAS", # The Essential Tremor Rating Assessment Scale + ], + "Dystonia": [ + "BFMDRS", # Burke–Fahn–Marsden Dystonia Rating Scale + "TWSTRS", # Toronto Western Spasmodic Torticollis Rating Scale + ], + "TS": [ + "YGTSS", # Yale Global Tic Severity Scale + "PUTS", # Premonitory Urge for Tics Scale + "TS-CGI", # Tourette Syndrome Clinical Global Impression + "Y-BOCS", # Yale–Brown Obsessive–Compulsive Scale + ], } -# Session scales presets (name, min, max) SESSION_SCALES_PRESETS: dict[str, list[tuple[str, str, str]]] = { "OCD": [ - ("Mood", "0", "10"), + ("Obsessions", "0", "10"), + ("Compulsions", "0", "10"), ("Anxiety", "0", "10"), + ("Mood", "0", "10"), ("Energy", "0", "10"), - ("OCD", "0", "10"), ], "MDD": [ - ("Mood", "0", "10"), + ("Rumination", "0", "10"), ("Anxiety", "0", "10"), + ("Mood", "0", "10"), ("Energy", "0", "10"), - ("Rumination", "0", "10"), ], "PD": [ ("Tremor", "0", "10"), ("Rigidity", "0", "10"), + ("Bradykinesia", "0", "10"), + ("Dyskinesia", "0", "10"), + ("Gait / balance", "0", "10"), + ("Paresthesia", "0", "10"), + ("Speech difficulty", "0", "10"), ], "ET": [ - ("Tremor", "0", "10"), - ("Rigidity", "0", "10"), + ("Action tremor", "0", "10"), + ("Resting tremor", "0", "10"), + ("Paresthesia", "0", "10"), + ("Speech difficulty", "0", "10"), + ], + "Dystonia": [ + ("Muscle contractions", "0", "10"), + ("Abnormal posture", "0", "10"), + ("Pain", "0", "10"), + ], + "TS": [ + ("Tic severity", "0", "10"), + ("Premonitory urge", "0", "10"), + ("Control over tics", "0", "10"), + ("Anxiety", "0", "10"), + ("Impulsivity", "0", "10"), ], } +PRESET_BUTTONS = ["OCD", "MDD", "PD", "ET", "Dystonia", "TS"] -# Available preset buttons -PRESET_BUTTONS = ["OCD", "MDD", "PD", "ET"] - -# UI Style constants COLORS = { "primary": "#ff8800", "background": "#23272f", @@ -163,7 +208,6 @@ "increment": {"width": 20, "height": 14}, } -# Placeholders PLACEHOLDERS = { "frequency": "Hz", "contact": "E#", diff --git a/src/clinical_dbs_annotator/models/session_data.py b/src/clinical_dbs_annotator/models/session_data.py index 58929dc..1e2392a 100644 --- a/src/clinical_dbs_annotator/models/session_data.py +++ b/src/clinical_dbs_annotator/models/session_data.py @@ -9,8 +9,7 @@ from datetime import datetime from pathlib import Path from typing import TextIO - -import pytz +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..config import TIMEZONE, TSV_COLUMNS from .clinical_scale import ClinicalScale, SessionScale @@ -138,6 +137,25 @@ def close_file(self) -> None: self.tsv_file = None self.tsv_writer = None + @staticmethod + def _resolve_timezone(): + if TIMEZONE in (None, "", "local"): + return None + try: + return ZoneInfo(TIMEZONE) + except ZoneInfoNotFoundError: + return None + + @staticmethod + def _timezone_string(dt: datetime) -> str: + tzinfo = dt.tzinfo + if isinstance(tzinfo, ZoneInfo): + name = tzinfo.key + else: + name = dt.tzname() or "local" + offset = dt.strftime("%z") + return f"{name} {offset}".strip() + def write_clinical_scales( self, scales: list[ClinicalScale], @@ -157,10 +175,11 @@ def write_clinical_scales( if not self.tsv_writer: raise ValueError("TSV file not opened. Call open_file() first.") - tz = pytz.timezone(TIMEZONE) - now_et = datetime.now(tz) - time_str = now_et.strftime("%H:%M:%S") - today = datetime.now().astimezone().strftime("%Y-%m-%d") + tz = self._resolve_timezone() + now_localized = datetime.now(tz) if tz is not None else datetime.now().astimezone() + time_str = now_localized.strftime("%H:%M:%S") + today = now_localized.strftime("%Y-%m-%d") + tz_str = self._timezone_string(now_localized) stim_dict = stimulation.to_dict() # If no scales have values, write a single row with null scale data @@ -169,6 +188,7 @@ def write_clinical_scales( row = { "date": today, "time": time_str, + "timezone": tz_str, "block_id": self.block_id, "group_ID": group, "session_ID": self.session_id, @@ -186,6 +206,7 @@ def write_clinical_scales( row = { "date": today, "time": time_str, + "timezone": tz_str, "block_id": self.block_id, "group_ID": group, "session_ID": self.session_id, @@ -220,11 +241,11 @@ def write_session_scales( if not self.tsv_writer: raise ValueError("TSV file not opened. Call open_file() first.") - # Get current time in Eastern timezone - tz = pytz.timezone(TIMEZONE) - now_et = datetime.now(tz) - time_str = now_et.strftime("%H:%M:%S") - today = datetime.now().astimezone().strftime("%Y-%m-%d") + tz = self._resolve_timezone() + now_localized = datetime.now(tz) if tz is not None else datetime.now().astimezone() + time_str = now_localized.strftime("%H:%M:%S") + today = now_localized.strftime("%Y-%m-%d") + tz_str = self._timezone_string(now_localized) stim_dict = stimulation.to_dict() # If no scales have values, write a single row with null scale data @@ -233,6 +254,7 @@ def write_session_scales( row = { "date": today, "time": time_str, + "timezone": tz_str, "block_id": self.block_id, "group_ID": group, "session_ID": self.session_id, @@ -250,6 +272,7 @@ def write_session_scales( row = { "date": today, "time": time_str, + "timezone": tz_str, "block_id": self.block_id, "group_ID": group, "session_ID": self.session_id, @@ -305,7 +328,7 @@ def initialize_simple_file(self, filepath: str) -> None: self.tsv_file = open(filepath, "w", newline="", encoding="utf-8") # Simple header: date, time, and annotation - fieldnames = ["date", "time", "annotation"] + fieldnames = ["date", "time", "timezone", "annotation"] self.tsv_writer = csv.DictWriter( self.tsv_file, @@ -335,7 +358,7 @@ def open_simple_file_append(self, filepath: str) -> None: except Exception: fieldnames = None - fieldnames = fieldnames or ["date", "time", "annotation"] + fieldnames = fieldnames or ["date", "time", "timezone", "annotation"] self.tsv_writer = csv.DictWriter( self.tsv_file, @@ -368,13 +391,16 @@ def write_simple_annotation(self, annotation: str) -> None: from datetime import datetime - time_str = datetime.now().astimezone().strftime("%H:%M:%S") - date_str = datetime.now().astimezone().strftime("%Y-%m-%d") + now_localized = datetime.now().astimezone() + time_str = now_localized.strftime("%H:%M:%S") + date_str = now_localized.strftime("%Y-%m-%d") + tz_str = self._timezone_string(now_localized) # Write row row = { "date": date_str, "time": time_str, + "timezone": tz_str, "annotation": annotation, } self.tsv_writer.writerow(row) diff --git a/src/clinical_dbs_annotator/version.py b/src/clinical_dbs_annotator/version.py new file mode 100644 index 0000000..17499f9 --- /dev/null +++ b/src/clinical_dbs_annotator/version.py @@ -0,0 +1,46 @@ +""" +Version helpers. + +The only hardcoded version string should live in `clinical_dbs_annotator/__init__.py`. +Everything else should derive the version dynamically. +""" + +from __future__ import annotations + +import re +from importlib import metadata +from pathlib import Path + +_DIST_NAME = "clinical-dbs-annotator" + + +def get_version() -> str: + """ + Return the package version. + + Prefers installed distribution metadata (works in packaged apps), + falling back to parsing `__init__.py` when running from a source checkout. + """ + try: + return metadata.version(_DIST_NAME) + except Exception as e: + init_path = Path(__file__).with_name("__init__.py") + text = init_path.read_text(encoding="utf-8") + m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']\s*$', text, flags=re.MULTILINE) + if not m: + raise RuntimeError(f"Could not determine version from {init_path}") from e + return m.group(1) + + +def get_pep440_base_version() -> str: + """ + Return the numeric x.y.z portion of the version. + + Useful for Windows file/product version fields that must be strictly numeric. + """ + v = get_version() + m = re.search(r"(\d+\.\d+\.\d+)", v) + if not m: + raise RuntimeError(f"Could not extract base version from {v!r}") + return m.group(1) + diff --git a/src/clinical_dbs_annotator/views/wizard_window.py b/src/clinical_dbs_annotator/views/wizard_window.py index 5a796bb..9262b66 100644 --- a/src/clinical_dbs_annotator/views/wizard_window.py +++ b/src/clinical_dbs_annotator/views/wizard_window.py @@ -85,7 +85,7 @@ def __init__(self, app): def _setup_window(self) -> None: """Configure the main window properties with responsive sizing.""" - self.setWindowTitle(f"{APP_NAME} {APP_VERSION}") + self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") # Set window icon icon_path = resource_path(os.path.join(ICONS_DIR, ICON_FILENAME)) diff --git a/uv.lock b/uv.lock index b6ecd2f..ae82715 100644 --- a/uv.lock +++ b/uv.lock @@ -45,7 +45,7 @@ name = "appscript" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lxml" }, + { name = "lxml", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/52/2fa70edfd98f0058219ecc2e365a3ba7aabd42db14ff9d7f44bbdcc5400d/appscript-1.4.0.tar.gz", hash = "sha256:b2c6fc770bf822ea45529c7084bc0ee340e67ab260016b01d28e0449ec8723be", size = 295279, upload-time = "2025-10-08T07:56:39.126Z" } wheels = [ @@ -185,7 +185,7 @@ dependencies = [ { name = "pyqtgraph" }, { name = "pyside6" }, { name = "python-docx" }, - { name = "pytz" }, + { name = "tzdata" }, ] [package.dev-dependencies] @@ -215,7 +215,7 @@ requires-dist = [ { name = "pyqtgraph", specifier = ">=0.14.0" }, { name = "pyside6", specifier = ">=6.11.0" }, { name = "python-docx", specifier = ">=1.1" }, - { name = "pytz", specifier = ">=2025.2" }, + { name = "tzdata", specifier = ">=2025.3" }, ] [package.metadata.requires-dev] @@ -1154,15 +1154,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, ] -[[package]] -name = "pytz" -version = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, -] - [[package]] name = "pywin32" version = "311"