dashboard sync app

This commit is contained in:
Tim Bendt
2025-12-16 17:13:26 -05:00
parent 73079f743a
commit d7c82a0da0
25 changed files with 4181 additions and 69 deletions

BIN
.coverage Normal file

Binary file not shown.

284
README.md
View File

@@ -1,13 +1,281 @@
# LŪK # luk
> Pronunced Look... > Pronounced "look" - as in "look at your Outlook data locally"
A collection of tools for syncing microsoft Mail and Calendars with local file-system based PIM standards like Maildir, VDIR, etc so you can use local TUI and CLI tools to read write and manage your mail and events. A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats like Maildir and vdir. Use your favorite terminal tools to manage your email and calendar.
## Features ## Features
- Download Emails to a local maildir - **Email Synchronization**: Sync emails with Microsoft Graph API to local Maildir format
- Download Events to a local VDIR - **Calendar Management**: Two-way calendar sync with vdir/ICS support
- two-way sync of locally changed files - **Task Integration**: Sync with Godspeed and TickTick task managers
- View OneDrive Folders and Files in your terminal - **TUI Dashboard**: Interactive terminal dashboard for monitoring sync progress
- a couple different ways to view email messages locally, but you should probably be using [aerc] - **Daemon Mode**: Background daemon with proper Unix logging
- **Cross-Platform**: Works on macOS, Linux, and Windows
## Quick Start
### Prerequisites
- Python 3.12 or higher
- `uv` package manager (recommended)
### Installation
```bash
# Clone the repository
git clone https://github.com/timothybendt/luk.git
cd luk
# Run the installation script
./install.sh
```
### Manual Installation
```bash
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -e .
# Setup configuration directories
mkdir -p ~/.config/luk
mkdir -p ~/.local/share/luk
```
## Configuration
Create a configuration file at `~/.config/luk/config.env`:
```bash
# Microsoft Graph settings
MICROSOFT_CLIENT_ID=your_client_id
MICROSOFT_TENANT_ID=your_tenant_id
# Email settings
MAILDIR_PATH=~/Mail
NOTES_DIR=~/Documents/Notes
# Godspeed settings
GODSPEED_EMAIL=your_email@example.com
GODSPEED_PASSWORD=your_password
GODSPEED_TOKEN=your_token
GODSPEED_SYNC_DIR=~/Documents/Godspeed
# TickTick settings
TICKTICK_CLIENT_ID=your_client_id
TICKTICK_CLIENT_SECRET=your_client_secret
```
## Usage
### Basic Commands
```bash
# Show help
luk --help
# Run sync with default settings
luk sync run
# Run with TUI dashboard
luk sync run --dashboard
# Start daemon mode
luk sync run --daemon
# Stop daemon
luk sync stop
# Check daemon status
luk sync status
```
### Sync Options
```bash
# Dry run (no changes)
luk sync run --dry-run
# Specify organization
luk sync run --org mycompany
# Enable notifications
luk sync run --notify
# Download attachments
luk sync run --download-attachments
# Two-way calendar sync
luk sync run --two-way-calendar
# Custom calendar directory
luk sync run --vdir ~/Calendars
```
### Dashboard Mode
The TUI dashboard provides real-time monitoring of sync operations:
- **Status Display**: Current sync status and metrics
- **Progress Bars**: Visual progress for each sync component
- **Activity Log**: Scrollable log of all sync activities
- **Keyboard Shortcuts**:
- `q`: Quit dashboard
- `l`: Toggle log visibility
- `r`: Refresh status
### Daemon Mode
Run luk as a background daemon with proper Unix logging:
```bash
# Start daemon
luk sync run --daemon
# Check status
luk sync status
# View logs
cat ~/.local/share/luk/luk.log
# Stop daemon
luk sync stop
```
Daemon logs are stored at `~/.local/share/luk/luk.log` with automatic rotation.
## Architecture
### Core Components
- **Sync Engine**: Handles email, calendar, and task synchronization
- **TUI Dashboard**: Interactive monitoring interface using Textual
- **Daemon Service**: Background service with logging and process management
- **Configuration**: Environment-based configuration system
### Directory Structure
```
src/
├── cli/ # CLI commands and interfaces
│ ├── sync.py # Main sync command
│ ├── sync_dashboard.py # TUI dashboard
│ ├── sync_daemon.py # Daemon service
│ └── ...
├── services/ # External service integrations
│ ├── microsoft_graph/ # Microsoft Graph API
│ ├── godspeed/ # Godspeed task manager
│ ├── ticktick/ # TickTick API
│ └── ...
└── utils/ # Utility functions
```
## Development
### Setup Development Environment
```bash
# Clone repository
git clone https://github.com/timothybendt/luk.git
cd luk
# Install development dependencies
uv sync --dev
# Run tests
uv run pytest
# Run linting
uv run ruff check .
uv run ruff format .
# Type checking
uv run mypy src/
```
### Project Structure
- `pyproject.toml`: Project configuration and dependencies
- `src/cli/`: CLI commands and user interfaces
- `src/services/`: External service integrations
- `src/utils/`: Shared utilities and helpers
- `tests/`: Test suite
### Building for Distribution
```bash
# Build package
uv run build
# Check package
uv run twine check dist/*
# Upload to PyPI (for maintainers)
uv run twine upload dist/*
```
## Troubleshooting
### Common Issues
1. **Authentication Errors**: Ensure Microsoft Graph credentials are properly configured
2. **Permission Denied**: Check file permissions for Maildir and calendar directories
3. **Daemon Not Starting**: Verify log directory exists and is writable
4. **TUI Not Rendering**: Ensure terminal supports Textual requirements
### Debug Mode
Enable debug logging:
```bash
export LOG_LEVEL=DEBUG
luk sync run --dry-run
```
### Log Files
- **Daemon Logs**: `~/.local/share/luk/luk.log`
- **Sync State**: `~/.local/share/luk/sync_state.json`
- **Configuration**: `~/.config/luk/`
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Run the test suite
6. Submit a pull request
### Code Style
This project uses:
- **Ruff** for linting and formatting
- **MyPy** for type checking
- **Black** for code formatting
- **Pre-commit** hooks for quality control
## License
MIT License - see LICENSE file for details.
## Support
- **Issues**: [GitHub Issues](https://github.com/timothybendt/luk/issues)
- **Documentation**: [GitHub Wiki](https://github.com/timothybendt/luk/wiki)
- **Discussions**: [GitHub Discussions](https://github.com/timothybendt/luk/discussions)
## Changelog
### v0.1.0
- Initial release
- Email synchronization with Microsoft Graph
- Calendar sync with vdir/ICS support
- Godspeed and TickTick integration
- TUI dashboard
- Daemon mode with logging
- Cross-platform support

88
check_env.py Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Environment validation script for GTD Terminal Tools."""
import sys
from pathlib import Path
# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from src.utils.platform import validate_environment
def main():
"""Run environment validation and exit with appropriate code."""
env_info = validate_environment()
print("GTD Terminal Tools - Environment Validation")
print("=" * 50)
# Platform info
platform_info = env_info["platform_info"]
print(f"Platform: {platform_info['system']} {platform_info['release']}")
print(
f"Python: {platform_info['python_version']} ({platform_info['python_implementation']})"
)
print(f"Supported: {'' if env_info['platform_supported'] else ''}")
print()
# Dependencies
print("Dependencies:")
all_deps_available = True
for dep, available in env_info["dependencies"].items():
status = "" if available else ""
print(f" {dep}: {status}")
if not available:
all_deps_available = False
print()
# Terminal compatibility
print("Terminal Compatibility:")
terminal_ok = True
for feature, supported in env_info["terminal_compatibility"].items():
status = "" if supported else ""
print(f" {feature}: {status}")
if not supported and feature in ["color_support", "textual_support"]:
terminal_ok = False
print()
# Directories
print("Directories:")
for dir_type, dir_path in [
("config", "config_dir"),
("data", "data_dir"),
("logs", "log_dir"),
]:
path = Path(env_info[dir_path])
exists = path.exists()
status = "" if exists else ""
print(f" {dir_type.capitalize()}: {env_info[dir_path]} {status}")
print()
# Recommendations
if env_info["recommendations"]:
print("Recommendations:")
for rec in env_info["recommendations"]:
print(f"{rec}")
print()
# Overall status
platform_ok = env_info["platform_supported"]
overall_ok = platform_ok and all_deps_available and terminal_ok
if overall_ok:
print("✓ Environment is ready for GTD Terminal Tools")
sys.exit(0)
else:
print("✗ Environment has issues that need to be addressed")
if not platform_ok:
print(" - Unsupported platform or Python version")
if not all_deps_available:
print(" - Missing dependencies")
if not terminal_ok:
print(" - Terminal compatibility issues")
sys.exit(1)
if __name__ == "__main__":
main()

187
install.sh Executable file
View File

@@ -0,0 +1,187 @@
#!/bin/bash
# Installation script for luk
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if Python 3.12+ is installed
check_python() {
print_status "Checking Python installation..."
if command -v python3 &> /dev/null; then
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
REQUIRED_VERSION="3.12"
if python3 -c "import sys; exit(0 if sys.version_info >= (3, 12) else 1)"; then
print_status "Python $PYTHON_VERSION found ✓"
else
print_error "Python $REQUIRED_VERSION or higher is required. Found: $PYTHON_VERSION"
exit 1
fi
else
print_error "Python 3 is not installed"
exit 1
fi
}
# Check if uv is installed, install if not
check_uv() {
print_status "Checking uv installation..."
if command -v uv &> /dev/null; then
print_status "uv found ✓"
else
print_warning "uv not found, installing..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.cargo/bin:$PATH"
if command -v uv &> /dev/null; then
print_status "uv installed successfully ✓"
else
print_error "Failed to install uv"
exit 1
fi
fi
}
# Install the package
install_package() {
print_status "Installing luk..."
# Create virtual environment and install
uv venv
source .venv/bin/activate
uv pip install -e .
print_status "Installation completed ✓"
}
# Setup configuration directories
setup_config() {
print_status "Setting up configuration directories..."
# Create necessary directories
mkdir -p "$HOME/.config/luk"
mkdir -p "$HOME/.local/share/luk"
mkdir -p "$HOME/.local/share/luk/logs"
# Create example configuration
cat > "$HOME/.config/luk/config.env" << EOF
# luk Configuration
# Copy this file and modify as needed
# Microsoft Graph settings
# These will be prompted for on first run
# MICROSOFT_CLIENT_ID=your_client_id
# MICROSOFT_TENANT_ID=your_tenant_id
# Email settings
MAILDIR_PATH=~/Mail
NOTES_DIR=~/Documents/Notes
# Godspeed settings
# GODSPEED_EMAIL=your_email@example.com
# GODSPEED_PASSWORD=your_password
# GODSPEED_TOKEN=your_token
# GODSPEED_SYNC_DIR=~/Documents/Godspeed
# TickTick settings
# TICKTICK_CLIENT_ID=your_client_id
# TICKTICK_CLIENT_SECRET=your_client_secret
# Sync settings
DEFAULT_ORG=corteva
DEFAULT_CALENDAR_DIR=~/Calendar
SYNC_INTERVAL=300 # 5 minutes
LOG_LEVEL=INFO
EOF
print_status "Configuration directories created ✓"
print_warning "Please edit $HOME/.config/luk/config.env with your settings"
}
# Create shell completions
setup_completions() {
print_status "Setting up shell completions..."
# Get the shell type
SHELL_TYPE=$(basename "$SHELL")
case $SHELL_TYPE in
bash)
echo 'eval "$(_LUK_COMPLETE=bash_source luk)"' >> "$HOME/.bashrc"
print_status "Bash completions added to ~/.bashrc"
;;
zsh)
echo 'eval "$(_LUK_COMPLETE=zsh_source luk)"' >> "$HOME/.zshrc"
print_status "Zsh completions added to ~/.zshrc"
;;
fish)
echo 'luk --completion | source' >> "$HOME/.config/fish/config.fish"
print_status "Fish completions added to ~/.config/fish/config.fish"
;;
*)
print_warning "Unsupported shell: $SHELL_TYPE"
;;
esac
}
# Run tests
run_tests() {
print_status "Running tests..."
source .venv/bin/activate
if uv run pytest tests/ -v; then
print_status "All tests passed ✓"
else
print_warning "Some tests failed, but installation will continue"
fi
}
# Main installation flow
main() {
echo
echo " luk - Look at your Outlook data locally"
echo " ========================================="
echo
print_status "Starting luk installation..."
check_python
check_uv
install_package
setup_config
setup_completions
run_tests
print_status "Installation completed successfully! 🎉"
echo
print_status "To get started:"
echo " 1. Source your shell profile: source ~/.bashrc (or ~/.zshrc)"
echo " 2. Configure your settings in ~/.config/luk/config.env"
echo " 3. Run: luk sync --help"
echo " 4. Try the dashboard: luk sync run --dashboard"
echo " 5. Start the daemon: luk sync run --daemon"
echo
print_status "For more information, see: https://github.com/timothybendt/luk"
}
# Run the installation
main "$@"

323
luk.egg-info/PKG-INFO Normal file
View File

@@ -0,0 +1,323 @@
Metadata-Version: 2.4
Name: luk
Version: 0.1.0
Summary: A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats. Look at your Outlook data locally.
Author-email: Timothy Bendt <timothy@example.com>
License: MIT
Project-URL: Homepage, https://github.com/timothybendt/luk
Project-URL: Repository, https://github.com/timothybendt/luk
Project-URL: Issues, https://github.com/timothybendt/luk/issues
Project-URL: Documentation, https://github.com/timothybendt/luk#readme
Keywords: email,calendar,tasks,sync,cli,microsoft-graph,outlook,maildir,vdir
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Office/Business :: Scheduling
Classifier: Topic :: Utilities
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: aiohttp>=3.11.18
Requires-Dist: certifi>=2025.4.26
Requires-Dist: click>=8.1.0
Requires-Dist: html2text>=2025.4.15
Requires-Dist: mammoth>=1.9.0
Requires-Dist: markitdown[all]>=0.1.1
Requires-Dist: msal>=1.32.3
Requires-Dist: openai>=1.78.1
Requires-Dist: orjson>=3.10.18
Requires-Dist: pillow>=11.2.1
Requires-Dist: python-dateutil>=2.9.0.post0
Requires-Dist: python-docx>=1.1.2
Requires-Dist: requests>=2.31.0
Requires-Dist: rich>=14.0.0
Requires-Dist: textual>=3.2.0
Requires-Dist: textual-image>=0.8.2
Requires-Dist: ticktick-py>=2.0.0
# luk
> Pronounced "look" - as in "look at your Outlook data locally"
A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats like Maildir and vdir. Use your favorite terminal tools to manage your email and calendar.
## Features
- **Email Synchronization**: Sync emails with Microsoft Graph API to local Maildir format
- **Calendar Management**: Two-way calendar sync with vdir/ICS support
- **Task Integration**: Sync with Godspeed and TickTick task managers
- **TUI Dashboard**: Interactive terminal dashboard for monitoring sync progress
- **Daemon Mode**: Background daemon with proper Unix logging
- **Cross-Platform**: Works on macOS, Linux, and Windows
## Quick Start
### Prerequisites
- Python 3.12 or higher
- `uv` package manager (recommended)
### Installation
```bash
# Clone the repository
git clone https://github.com/timothybendt/luk.git
cd luk
# Run the installation script
./install.sh
```
### Manual Installation
```bash
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -e .
# Setup configuration directories
mkdir -p ~/.config/luk
mkdir -p ~/.local/share/luk
```
## Configuration
Create a configuration file at `~/.config/luk/config.env`:
```bash
# Microsoft Graph settings
MICROSOFT_CLIENT_ID=your_client_id
MICROSOFT_TENANT_ID=your_tenant_id
# Email settings
MAILDIR_PATH=~/Mail
NOTES_DIR=~/Documents/Notes
# Godspeed settings
GODSPEED_EMAIL=your_email@example.com
GODSPEED_PASSWORD=your_password
GODSPEED_TOKEN=your_token
GODSPEED_SYNC_DIR=~/Documents/Godspeed
# TickTick settings
TICKTICK_CLIENT_ID=your_client_id
TICKTICK_CLIENT_SECRET=your_client_secret
```
## Usage
### Basic Commands
```bash
# Show help
luk --help
# Run sync with default settings
luk sync run
# Run with TUI dashboard
luk sync run --dashboard
# Start daemon mode
luk sync run --daemon
# Stop daemon
luk sync stop
# Check daemon status
luk sync status
```
### Sync Options
```bash
# Dry run (no changes)
luk sync run --dry-run
# Specify organization
luk sync run --org mycompany
# Enable notifications
luk sync run --notify
# Download attachments
luk sync run --download-attachments
# Two-way calendar sync
luk sync run --two-way-calendar
# Custom calendar directory
luk sync run --vdir ~/Calendars
```
### Dashboard Mode
The TUI dashboard provides real-time monitoring of sync operations:
- **Status Display**: Current sync status and metrics
- **Progress Bars**: Visual progress for each sync component
- **Activity Log**: Scrollable log of all sync activities
- **Keyboard Shortcuts**:
- `q`: Quit dashboard
- `l`: Toggle log visibility
- `r`: Refresh status
### Daemon Mode
Run luk as a background daemon with proper Unix logging:
```bash
# Start daemon
luk sync run --daemon
# Check status
luk sync status
# View logs
cat ~/.local/share/luk/luk.log
# Stop daemon
luk sync stop
```
Daemon logs are stored at `~/.local/share/luk/luk.log` with automatic rotation.
## Architecture
### Core Components
- **Sync Engine**: Handles email, calendar, and task synchronization
- **TUI Dashboard**: Interactive monitoring interface using Textual
- **Daemon Service**: Background service with logging and process management
- **Configuration**: Environment-based configuration system
### Directory Structure
```
src/
├── cli/ # CLI commands and interfaces
│ ├── sync.py # Main sync command
│ ├── sync_dashboard.py # TUI dashboard
│ ├── sync_daemon.py # Daemon service
│ └── ...
├── services/ # External service integrations
│ ├── microsoft_graph/ # Microsoft Graph API
│ ├── godspeed/ # Godspeed task manager
│ ├── ticktick/ # TickTick API
│ └── ...
└── utils/ # Utility functions
```
## Development
### Setup Development Environment
```bash
# Clone repository
git clone https://github.com/timothybendt/luk.git
cd luk
# Install development dependencies
uv sync --dev
# Run tests
uv run pytest
# Run linting
uv run ruff check .
uv run ruff format .
# Type checking
uv run mypy src/
```
### Project Structure
- `pyproject.toml`: Project configuration and dependencies
- `src/cli/`: CLI commands and user interfaces
- `src/services/`: External service integrations
- `src/utils/`: Shared utilities and helpers
- `tests/`: Test suite
### Building for Distribution
```bash
# Build package
uv run build
# Check package
uv run twine check dist/*
# Upload to PyPI (for maintainers)
uv run twine upload dist/*
```
## Troubleshooting
### Common Issues
1. **Authentication Errors**: Ensure Microsoft Graph credentials are properly configured
2. **Permission Denied**: Check file permissions for Maildir and calendar directories
3. **Daemon Not Starting**: Verify log directory exists and is writable
4. **TUI Not Rendering**: Ensure terminal supports Textual requirements
### Debug Mode
Enable debug logging:
```bash
export LOG_LEVEL=DEBUG
luk sync run --dry-run
```
### Log Files
- **Daemon Logs**: `~/.local/share/luk/luk.log`
- **Sync State**: `~/.local/share/luk/sync_state.json`
- **Configuration**: `~/.config/luk/`
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Run the test suite
6. Submit a pull request
### Code Style
This project uses:
- **Ruff** for linting and formatting
- **MyPy** for type checking
- **Black** for code formatting
- **Pre-commit** hooks for quality control
## License
MIT License - see LICENSE file for details.
## Support
- **Issues**: [GitHub Issues](https://github.com/timothybendt/luk/issues)
- **Documentation**: [GitHub Wiki](https://github.com/timothybendt/luk/wiki)
- **Discussions**: [GitHub Discussions](https://github.com/timothybendt/luk/discussions)
## Changelog
### v0.1.0
- Initial release
- Email synchronization with Microsoft Graph
- Calendar sync with vdir/ICS support
- Godspeed and TickTick integration
- TUI dashboard
- Daemon mode with logging
- Cross-platform support

76
luk.egg-info/SOURCES.txt Normal file
View File

@@ -0,0 +1,76 @@
README.md
pyproject.toml
luk.egg-info/PKG-INFO
luk.egg-info/SOURCES.txt
luk.egg-info/dependency_links.txt
luk.egg-info/entry_points.txt
luk.egg-info/requires.txt
luk.egg-info/top_level.txt
src/cli/__init__.py
src/cli/__main__.py
src/cli/calendar.py
src/cli/drive.py
src/cli/email.py
src/cli/gitlab_monitor.py
src/cli/godspeed.py
src/cli/sync.py
src/cli/sync_daemon.py
src/cli/sync_dashboard.py
src/cli/ticktick.py
src/maildir_gtd/__init__.py
src/maildir_gtd/app.py
src/maildir_gtd/email_viewer.tcss
src/maildir_gtd/message_store.py
src/maildir_gtd/utils.py
src/maildir_gtd/actions/__init__.py
src/maildir_gtd/actions/archive.py
src/maildir_gtd/actions/delete.py
src/maildir_gtd/actions/newest.py
src/maildir_gtd/actions/next.py
src/maildir_gtd/actions/oldest.py
src/maildir_gtd/actions/open.py
src/maildir_gtd/actions/previous.py
src/maildir_gtd/actions/show_message.py
src/maildir_gtd/actions/task.py
src/maildir_gtd/screens/CreateTask.py
src/maildir_gtd/screens/DocumentViewer.py
src/maildir_gtd/screens/OpenMessage.py
src/maildir_gtd/screens/__init__.py
src/maildir_gtd/widgets/ContentContainer.py
src/maildir_gtd/widgets/EnvelopeHeader.py
src/maildir_gtd/widgets/__init__.py
src/services/__init__.py
src/services/gitlab_monitor/__init__.py
src/services/gitlab_monitor/config.py
src/services/gitlab_monitor/daemon.py
src/services/gitlab_monitor/gitlab_client.py
src/services/gitlab_monitor/notifications.py
src/services/gitlab_monitor/openai_analyzer.py
src/services/godspeed/__init__.py
src/services/godspeed/client.py
src/services/godspeed/config.py
src/services/godspeed/sync.py
src/services/himalaya/__init__.py
src/services/himalaya/client.py
src/services/microsoft_graph/__init__.py
src/services/microsoft_graph/auth.py
src/services/microsoft_graph/calendar.py
src/services/microsoft_graph/client.py
src/services/microsoft_graph/mail.py
src/services/taskwarrior/__init__.py
src/services/taskwarrior/client.py
src/services/ticktick/__init__.py
src/services/ticktick/auth.py
src/services/ticktick/client.py
src/services/ticktick/direct_client.py
src/utils/calendar_utils.py
src/utils/file_icons.py
src/utils/notifications.py
src/utils/platform.py
src/utils/ticktick_utils.py
src/utils/mail_utils/__init__.py
src/utils/mail_utils/helpers.py
src/utils/mail_utils/maildir.py
tests/test_platform.py
tests/test_sync_daemon.py
tests/test_sync_dashboard.py

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
[console_scripts]
luk = src.cli.__main__:main

17
luk.egg-info/requires.txt Normal file
View File

@@ -0,0 +1,17 @@
aiohttp>=3.11.18
certifi>=2025.4.26
click>=8.1.0
html2text>=2025.4.15
mammoth>=1.9.0
markitdown[all]>=0.1.1
msal>=1.32.3
openai>=1.78.1
orjson>=3.10.18
pillow>=11.2.1
python-dateutil>=2.9.0.post0
python-docx>=1.1.2
requests>=2.31.0
rich>=14.0.0
textual>=3.2.0
textual-image>=0.8.2
ticktick-py>=2.0.0

View File

@@ -0,0 +1 @@
src

View File

@@ -1,9 +1,27 @@
[project] [project]
name = "gtd-terminal-tools" name = "luk"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "A CLI tool for syncing Microsoft Outlook email, calendar, and tasks to local file-based formats. Look at your Outlook data locally."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = {text = "MIT"}
authors = [
{name = "Timothy Bendt", email = "timothy@example.com"}
]
keywords = ["email", "calendar", "tasks", "sync", "cli", "microsoft-graph", "outlook", "maildir", "vdir"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Email",
"Topic :: Office/Business :: Scheduling",
"Topic :: Utilities",
]
dependencies = [ dependencies = [
"aiohttp>=3.11.18", "aiohttp>=3.11.18",
"certifi>=2025.4.26", "certifi>=2025.4.26",
@@ -24,8 +42,89 @@ dependencies = [
"ticktick-py>=2.0.0", "ticktick-py>=2.0.0",
] ]
[project.scripts]
luk = "src.cli.__main__:main"
[project.urls]
Homepage = "https://github.com/timothybendt/luk"
Repository = "https://github.com/timothybendt/luk"
Issues = "https://github.com/timothybendt/luk/issues"
Documentation = "https://github.com/timothybendt/luk#readme"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"ruff>=0.11.8", "ruff>=0.11.8",
"textual>=3.2.0", "textual>=3.2.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"black>=24.0.0",
"mypy>=1.8.0",
"pre-commit>=3.5.0",
"build>=1.0.0",
"twine>=5.0.0",
] ]
[tool.ruff]
line-length = 88
target-version = "py312"
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "DTZ", "T10", "EM", "ISC", "ICN", "G", "PIE", "PYI", "PT", "Q", "RSE", "RET", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PGH", "PL", "TRY", "NPY", "RUF"]
ignore = ["E501", "PLR0913", "PLR0915"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-fail-under=80",
"-v"
]
asyncio_mode = "auto"
[tool.black]
line-length = 88
target-version = ['py312']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.setuptools.package-data]
"*" = ["*.tcss", "*.css", "*.json", "*.md"]

View File

@@ -1,4 +1,10 @@
from . import cli from . import cli
if __name__ == "__main__":
def main():
"""Main entry point for the CLI."""
cli() cli()
if __name__ == "__main__":
main()

View File

@@ -31,7 +31,7 @@ def start(config, daemon):
cmd.extend(["--config", config]) cmd.extend(["--config", config])
# Create pid file # Create pid file
pid_file = os.path.expanduser("~/.config/gtd-tools/gitlab_monitor.pid") pid_file = os.path.expanduser("~/.config/luk/gitlab_monitor.pid")
Path(pid_file).parent.mkdir(parents=True, exist_ok=True) Path(pid_file).parent.mkdir(parents=True, exist_ok=True)
# Start daemon process # Start daemon process
@@ -61,7 +61,7 @@ def start(config, daemon):
@gitlab_monitor.command() @gitlab_monitor.command()
def stop(): def stop():
"""Stop the GitLab pipeline monitoring daemon.""" """Stop the GitLab pipeline monitoring daemon."""
pid_file = os.path.expanduser("~/.config/gtd-tools/gitlab_monitor.pid") pid_file = os.path.expanduser("~/.config/luk/gitlab_monitor.pid")
if not os.path.exists(pid_file): if not os.path.exists(pid_file):
click.echo("Daemon is not running (no PID file found)") click.echo("Daemon is not running (no PID file found)")
@@ -88,7 +88,7 @@ def stop():
@gitlab_monitor.command() @gitlab_monitor.command()
def status(): def status():
"""Check the status of the GitLab pipeline monitoring daemon.""" """Check the status of the GitLab pipeline monitoring daemon."""
pid_file = os.path.expanduser("~/.config/gtd-tools/gitlab_monitor.pid") pid_file = os.path.expanduser("~/.config/luk/gitlab_monitor.pid")
if not os.path.exists(pid_file): if not os.path.exists(pid_file):
click.echo("Daemon is not running") click.echo("Daemon is not running")

View File

@@ -1,6 +1,8 @@
import click import click
import asyncio import asyncio
import os import os
import sys
import signal
import json import json
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -31,7 +33,7 @@ from src.services.godspeed.sync import GodspeedSync
# Timing state management # Timing state management
def get_sync_state_file(): def get_sync_state_file():
"""Get the path to the sync state file.""" """Get the path to the sync state file."""
return os.path.expanduser("~/.local/share/gtd-terminal-tools/sync_state.json") return os.path.expanduser("~/.local/share/luk/sync_state.json")
def load_sync_state(): def load_sync_state():
@@ -97,7 +99,7 @@ def get_godspeed_sync_directory():
return docs_dir return docs_dir
# Fall back to data directory # Fall back to data directory
data_dir = home / ".local" / "share" / "gtd-terminal-tools" / "godspeed" data_dir = home / ".local" / "share" / "luk" / "godspeed"
return data_dir return data_dir
@@ -265,11 +267,12 @@ async def fetch_calendar_async(
# Update progress bar with total events # Update progress bar with total events
progress.update(task_id, total=total_events) progress.update(task_id, total=total_events)
# Define org_vdir_path up front if vdir_path is specified
org_vdir_path = os.path.join(vdir_path, org_name) if vdir_path else None
# Save events to appropriate format # Save events to appropriate format
if not dry_run: if not dry_run:
if vdir_path: if vdir_path and org_vdir_path:
# Create org-specific directory within vdir path
org_vdir_path = os.path.join(vdir_path, org_name)
progress.console.print( progress.console.print(
f"[cyan]Saving events to vdir: {org_vdir_path}[/cyan]" f"[cyan]Saving events to vdir: {org_vdir_path}[/cyan]"
) )
@@ -342,7 +345,7 @@ async def fetch_calendar_async(
progress.update(task_id, total=next_total_events) progress.update(task_id, total=next_total_events)
if not dry_run: if not dry_run:
if vdir_path: if vdir_path and org_vdir_path:
save_events_to_vdir( save_events_to_vdir(
next_events, org_vdir_path, progress, task_id, dry_run next_events, org_vdir_path, progress, task_id, dry_run
) )
@@ -494,9 +497,9 @@ async def _sync_outlook_data(
os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) + f"/{org}" os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) + f"/{org}"
) )
messages_before = 0 messages_before = 0
if notify:
new_dir = os.path.join(maildir_path, "new") new_dir = os.path.join(maildir_path, "new")
cur_dir = os.path.join(maildir_path, "cur") cur_dir = os.path.join(maildir_path, "cur")
if notify:
if os.path.exists(new_dir): if os.path.exists(new_dir):
messages_before += len([f for f in os.listdir(new_dir) if ".eml" in f]) messages_before += len([f for f in os.listdir(new_dir) if ".eml" in f])
if os.path.exists(cur_dir): if os.path.exists(cur_dir):
@@ -572,7 +575,51 @@ async def _sync_outlook_data(
click.echo("Sync complete.") click.echo("Sync complete.")
@click.command() @click.group()
def sync():
"""Email and calendar synchronization."""
pass
def daemonize():
"""Properly daemonize the process for Unix systems."""
# First fork
try:
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
except OSError as e:
sys.stderr.write(f"Fork #1 failed: {e}\n")
sys.exit(1)
# Decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# Second fork
try:
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
except OSError as e:
sys.stderr.write(f"Fork #2 failed: {e}\n")
sys.exit(1)
# Redirect standard file descriptors
sys.stdout.flush()
sys.stderr.flush()
si = open(os.devnull, "r")
so = open(os.devnull, "a+")
se = open(os.devnull, "a+")
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
@sync.command()
@click.option( @click.option(
"--dry-run", "--dry-run",
is_flag=True, is_flag=True,
@@ -628,13 +675,19 @@ async def _sync_outlook_data(
help="Run in daemon mode.", help="Run in daemon mode.",
default=False, default=False,
) )
@click.option(
"--dashboard",
is_flag=True,
help="Run with TUI dashboard.",
default=False,
)
@click.option( @click.option(
"--notify", "--notify",
is_flag=True, is_flag=True,
help="Send macOS notifications for new email messages", help="Send macOS notifications for new email messages",
default=False, default=False,
) )
def sync( def run(
dry_run, dry_run,
vdir, vdir,
icsfile, icsfile,
@@ -645,23 +698,31 @@ def sync(
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
daemon, daemon,
dashboard,
notify, notify,
): ):
if daemon: if dashboard:
asyncio.run( from .sync_dashboard import run_dashboard_sync
daemon_mode(
dry_run, asyncio.run(run_dashboard_sync())
vdir, elif daemon:
icsfile, from .sync_daemon import create_daemon_config, SyncDaemon
org,
days_back, config = create_daemon_config(
days_forward, dry_run=dry_run,
continue_iteration, vdir=vdir,
download_attachments, icsfile=icsfile,
two_way_calendar, org=org,
notify, days_back=days_back,
) days_forward=days_forward,
continue_iteration=continue_iteration,
download_attachments=download_attachments,
two_way_calendar=two_way_calendar,
notify=notify,
) )
daemon_instance = SyncDaemon(config)
daemon_instance.start()
else: else:
asyncio.run( asyncio.run(
_sync_outlook_data( _sync_outlook_data(
@@ -679,6 +740,55 @@ def sync(
) )
@sync.command()
def stop():
"""Stop the sync daemon."""
pid_file = os.path.expanduser("~/.config/luk/luk.pid")
if not os.path.exists(pid_file):
click.echo("Daemon is not running (no PID file found)")
return
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
# Send SIGTERM to process
os.kill(pid, signal.SIGTERM)
# Remove PID file
os.unlink(pid_file)
click.echo(f"Daemon stopped (PID {pid})")
except (ValueError, ProcessLookupError, OSError) as e:
click.echo(f"Error stopping daemon: {e}")
# Clean up stale PID file
if os.path.exists(pid_file):
os.unlink(pid_file)
@sync.command()
def status():
"""Check the status of the sync daemon."""
pid_file = os.path.expanduser("~/.config/luk/luk.pid")
if not os.path.exists(pid_file):
click.echo("Daemon is not running")
return
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
# Check if process exists
os.kill(pid, 0) # Send signal 0 to check if process exists
click.echo(f"Daemon is running (PID {pid})")
except (ValueError, ProcessLookupError, OSError):
click.echo("Daemon is not running (stale PID file)")
# Clean up stale PID file
os.unlink(pid_file)
def check_calendar_changes(vdir_path, org): def check_calendar_changes(vdir_path, org):
""" """
Check if there are local calendar changes that need syncing. Check if there are local calendar changes that need syncing.

329
src/cli/sync_daemon.py Normal file
View File

@@ -0,0 +1,329 @@
"""Daemon mode with proper Unix logging."""
import os
import sys
import logging
import logging.handlers
import asyncio
import time
import signal
import json
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, Any
from src.cli.sync import _sync_outlook_data, should_run_godspeed_sync, should_run_sweep
from src.cli.sync import run_godspeed_sync, run_task_sweep, load_sync_state
class SyncDaemon:
"""Proper daemon with Unix logging."""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.running = False
self.pid_file = Path(
config.get("pid_file", "~/.config/luk/luk.pid")
).expanduser()
self.log_file = Path(
config.get("log_file", "~/.local/share/luk/luk.log")
).expanduser()
self.sync_interval = config.get("sync_interval", 300) # 5 minutes
self.check_interval = config.get("check_interval", 10) # 10 seconds
self.logger = self._setup_logging()
def _setup_logging(self) -> logging.Logger:
"""Setup proper Unix logging."""
logger = logging.getLogger("sync_daemon")
logger.setLevel(logging.INFO)
# Ensure log directory exists
self.log_file.parent.mkdir(parents=True, exist_ok=True)
# Rotating file handler (10MB max, keep 5 backups)
handler = logging.handlers.RotatingFileHandler(
self.log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding="utf-8",
)
# Log format
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def daemonize(self) -> None:
"""Properly daemonize the process for Unix systems."""
# First fork
try:
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
except OSError as e:
sys.stderr.write(f"Fork #1 failed: {e}\n")
sys.exit(1)
# Decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# Second fork
try:
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
except OSError as e:
sys.stderr.write(f"Fork #2 failed: {e}\n")
sys.exit(1)
# Redirect standard file descriptors to /dev/null
sys.stdout.flush()
sys.stderr.flush()
si = open(os.devnull, "r")
so = open(os.devnull, "a+")
se = open(os.devnull, "a+")
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
# Write PID file
self.pid_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.pid_file, "w") as f:
f.write(str(os.getpid()))
def start(self) -> None:
"""Start the daemon."""
# Check if already running
if self.is_running():
print(f"Daemon is already running (PID {self.get_pid()})")
return
print("Starting sync daemon...")
self.daemonize()
# Setup signal handlers
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
self.logger.info("Sync daemon started")
self.running = True
# Run the daemon loop
asyncio.run(self._daemon_loop())
def stop(self) -> None:
"""Stop the daemon."""
if not self.is_running():
print("Daemon is not running")
return
try:
pid = self.get_pid()
os.kill(pid, signal.SIGTERM)
# Wait for process to exit
for _ in range(10):
try:
os.kill(pid, 0) # Check if process exists
time.sleep(0.5)
except ProcessLookupError:
break
else:
# Force kill if still running
os.kill(pid, signal.SIGKILL)
# Remove PID file
if self.pid_file.exists():
self.pid_file.unlink()
print(f"Daemon stopped (PID {pid})")
self.logger.info("Sync daemon stopped")
except Exception as e:
print(f"Error stopping daemon: {e}")
def status(self) -> None:
"""Check daemon status."""
if not self.is_running():
print("Daemon is not running")
return
pid = self.get_pid()
print(f"Daemon is running (PID {pid})")
# Show recent log entries
try:
with open(self.log_file, "r") as f:
lines = f.readlines()
if lines:
print("\nRecent log entries:")
for line in lines[-5:]:
print(f" {line.strip()}")
except Exception:
pass
def is_running(self) -> bool:
"""Check if daemon is running."""
if not self.pid_file.exists():
return False
try:
pid = self.get_pid()
os.kill(pid, 0) # Check if process exists
return True
except (ValueError, ProcessLookupError, OSError):
# Stale PID file, remove it
if self.pid_file.exists():
self.pid_file.unlink()
return False
def get_pid(self) -> int:
"""Get PID from file."""
with open(self.pid_file, "r") as f:
return int(f.read().strip())
def _signal_handler(self, signum, frame):
"""Handle shutdown signals."""
self.logger.info(f"Received signal {signum}, shutting down...")
self.running = False
# Remove PID file
if self.pid_file.exists():
self.pid_file.unlink()
sys.exit(0)
async def _daemon_loop(self) -> None:
"""Main daemon loop."""
last_sync_time = time.time() - self.sync_interval # Force initial sync
while self.running:
try:
current_time = time.time()
if current_time - last_sync_time >= self.sync_interval:
self.logger.info("Performing scheduled sync...")
await self._perform_sync()
last_sync_time = current_time
self.logger.info("Scheduled sync completed")
else:
# Check for changes
changes_detected = await self._check_for_changes()
if changes_detected:
self.logger.info("Changes detected, triggering sync...")
await self._perform_sync()
last_sync_time = current_time
else:
self.logger.debug("No changes detected")
await asyncio.sleep(self.check_interval)
except Exception as e:
self.logger.error(f"Error in daemon loop: {e}")
await asyncio.sleep(30) # Wait before retrying
async def _perform_sync(self) -> None:
"""Perform a full sync."""
try:
await _sync_outlook_data(
dry_run=self.config.get("dry_run", False),
vdir=self.config.get("vdir", "~/Calendar"),
icsfile=self.config.get("icsfile"),
org=self.config.get("org", "corteva"),
days_back=self.config.get("days_back", 1),
days_forward=self.config.get("days_forward", 30),
continue_iteration=self.config.get("continue_iteration", False),
download_attachments=self.config.get("download_attachments", False),
two_way_calendar=self.config.get("two_way_calendar", False),
notify=self.config.get("notify", False),
)
self.logger.info("Sync completed successfully")
except Exception as e:
self.logger.error(f"Sync failed: {e}")
async def _check_for_changes(self) -> bool:
"""Check if there are changes that require syncing."""
try:
# Check Godspeed operations
godspeed_sync_due = should_run_godspeed_sync()
sweep_due = should_run_sweep()
if godspeed_sync_due or sweep_due:
self.logger.info("Godspeed operations due")
return True
# Add other change detection logic here
# For now, just return False
return False
except Exception as e:
self.logger.error(f"Error checking for changes: {e}")
return False
def create_daemon_config(**kwargs) -> Dict[str, Any]:
"""Create daemon configuration from command line args."""
return {
"dry_run": kwargs.get("dry_run", False),
"vdir": kwargs.get("vdir", "~/Calendar"),
"icsfile": kwargs.get("icsfile"),
"org": kwargs.get("org", "corteva"),
"days_back": kwargs.get("days_back", 1),
"days_forward": kwargs.get("days_forward", 30),
"continue_iteration": kwargs.get("continue_iteration", False),
"download_attachments": kwargs.get("download_attachments", False),
"two_way_calendar": kwargs.get("two_way_calendar", False),
"notify": kwargs.get("notify", False),
"pid_file": kwargs.get("pid_file", "~/.config/luk/luk.pid"),
"log_file": kwargs.get("log_file", "~/.local/share/luk/luk.log"),
"sync_interval": kwargs.get("sync_interval", 300),
"check_interval": kwargs.get("check_interval", 10),
}
def main():
"""Main daemon entry point."""
import argparse
parser = argparse.ArgumentParser(description="Sync daemon management")
parser.add_argument(
"action", choices=["start", "stop", "status", "logs"], help="Action to perform"
)
parser.add_argument("--dry-run", action="store_true", help="Dry run mode")
parser.add_argument("--org", default="corteva", help="Organization name")
parser.add_argument("--vdir", default="~/Calendar", help="Calendar directory")
parser.add_argument("--notify", action="store_true", help="Enable notifications")
args = parser.parse_args()
config = create_daemon_config(
dry_run=args.dry_run, org=args.org, vdir=args.vdir, notify=args.notify
)
daemon = SyncDaemon(config)
if args.action == "start":
daemon.start()
elif args.action == "stop":
daemon.stop()
elif args.action == "status":
daemon.status()
elif args.action == "logs":
try:
with open(daemon.log_file, "r") as f:
print(f.read())
except Exception as e:
print(f"Error reading logs: {e}")
if __name__ == "__main__":
main()

680
src/cli/sync_dashboard.py Normal file
View File

@@ -0,0 +1,680 @@
"""TUI dashboard for sync progress with scrollable logs."""
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Header,
Footer,
Static,
ProgressBar,
Log,
ListView,
ListItem,
Label,
)
from textual.reactive import reactive
from textual.binding import Binding
from rich.text import Text
from datetime import datetime, timedelta
import asyncio
from typing import Dict, Any, Optional, List, Callable
# Default sync interval in seconds (5 minutes)
DEFAULT_SYNC_INTERVAL = 300
# Futuristic spinner frames
# SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
# Alternative spinners you could use:
# SPINNER_FRAMES = ["◢", "◣", "◤", "◥"] # Rotating triangle
SPINNER_FRAMES = ["▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▰▰▰▰▱", "▰▰▰▰▰", "▱▰▰▰▰", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰"] # Loading bar
# SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] # Braille dots
# SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] # Circle quarters
# SPINNER_FRAMES = ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"] # Braille orbit
class TaskStatus:
"""Status constants for tasks."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
ERROR = "error"
class TaskListItem(ListItem):
"""A list item representing a sync task."""
def __init__(self, task_id: str, task_name: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.task_id = task_id
self.task_name = task_name
self.status = TaskStatus.PENDING
self.progress = 0
self.total = 100
self.spinner_frame = 0
def compose(self) -> ComposeResult:
"""Compose the task item layout."""
yield Static(self._build_content_text(), id=f"task-content-{self.task_id}")
def _get_status_icon(self) -> str:
"""Get icon based on status."""
if self.status == TaskStatus.RUNNING:
return SPINNER_FRAMES[self.spinner_frame % len(SPINNER_FRAMES)]
icons = {
TaskStatus.PENDING: "",
TaskStatus.COMPLETED: "",
TaskStatus.ERROR: "",
}
return icons.get(self.status, "")
def advance_spinner(self) -> None:
"""Advance the spinner to the next frame."""
self.spinner_frame = (self.spinner_frame + 1) % len(SPINNER_FRAMES)
def _get_status_color(self) -> str:
"""Get color based on status."""
colors = {
TaskStatus.PENDING: "dim",
TaskStatus.RUNNING: "cyan",
TaskStatus.COMPLETED: "bright_white",
TaskStatus.ERROR: "red",
}
return colors.get(self.status, "white")
def _build_content_text(self) -> Text:
"""Build the task content text."""
icon = self._get_status_icon()
color = self._get_status_color()
# Use green checkmark for completed, but white text for readability
if self.status == TaskStatus.RUNNING:
progress_pct = (
int((self.progress / self.total) * 100) if self.total > 0 else 0
)
text = Text()
text.append(f"{icon} ", style="cyan")
text.append(f"{self.task_name} [{progress_pct}%]", style=color)
return text
elif self.status == TaskStatus.COMPLETED:
text = Text()
text.append(f"{icon} ", style="green") # Green checkmark
text.append(f"{self.task_name} [Done]", style=color)
return text
elif self.status == TaskStatus.ERROR:
text = Text()
text.append(f"{icon} ", style="red")
text.append(f"{self.task_name} [Error]", style=color)
return text
else:
return Text(f"{icon} {self.task_name}", style=color)
def update_display(self) -> None:
"""Update the display of this item."""
try:
content = self.query_one(f"#task-content-{self.task_id}", Static)
content.update(self._build_content_text())
except Exception:
pass
class SyncDashboard(App):
"""TUI dashboard for sync operations."""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("ctrl+c", "quit", "Quit"),
Binding("s", "sync_now", "Sync Now"),
Binding("r", "refresh", "Refresh"),
Binding("+", "increase_interval", "+Interval"),
Binding("-", "decrease_interval", "-Interval"),
Binding("up", "cursor_up", "Up", show=False),
Binding("down", "cursor_down", "Down", show=False),
]
CSS = """
.dashboard {
height: 100%;
layout: horizontal;
}
.sidebar {
width: 30;
height: 100%;
border: solid $primary;
padding: 0;
}
.sidebar-title {
text-style: bold;
padding: 1;
background: $primary-darken-2;
}
.countdown-container {
height: 3;
padding: 0 1;
border-top: solid $primary;
background: $surface;
}
.countdown-text {
text-align: center;
}
.main-panel {
width: 1fr;
height: 100%;
padding: 0;
}
.task-header {
height: 5;
padding: 1;
border-bottom: solid $primary;
}
.task-name {
text-style: bold;
}
.progress-row {
height: 3;
padding: 0 1;
}
.log-container {
height: 1fr;
border: solid $primary;
padding: 0;
}
.log-title {
padding: 0 1;
background: $primary-darken-2;
}
ListView {
height: 1fr;
}
ListItem {
padding: 0 1;
}
ListItem:hover {
background: $primary-darken-1;
}
Log {
height: 1fr;
border: none;
}
ProgressBar {
width: 1fr;
padding: 0 1;
}
"""
selected_task: reactive[str] = reactive("archive")
sync_interval: reactive[int] = reactive(DEFAULT_SYNC_INTERVAL)
next_sync_time: reactive[float] = reactive(0.0)
def __init__(self, sync_interval: int = DEFAULT_SYNC_INTERVAL):
super().__init__()
self._mounted: asyncio.Event = asyncio.Event()
self._task_logs: Dict[str, List[str]] = {}
self._task_items: Dict[str, TaskListItem] = {}
self._sync_callback: Optional[Callable] = None
self._countdown_task: Optional[asyncio.Task] = None
self._spinner_task: Optional[asyncio.Task] = None
self._initial_sync_interval = sync_interval
def compose(self) -> ComposeResult:
"""Compose the dashboard layout."""
yield Header()
with Horizontal(classes="dashboard"):
# Sidebar with task list
with Vertical(classes="sidebar"):
yield Static("Tasks", classes="sidebar-title")
yield ListView(
# Stage 1: Sync local changes to server
TaskListItem("archive", "Archive Mail", id="task-archive"),
TaskListItem("outbox", "Outbox Send", id="task-outbox"),
# Stage 2: Fetch from server
TaskListItem("inbox", "Inbox Sync", id="task-inbox"),
TaskListItem("calendar", "Calendar Sync", id="task-calendar"),
# Stage 3: Task management
TaskListItem("godspeed", "Godspeed Sync", id="task-godspeed"),
TaskListItem("sweep", "Task Sweep", id="task-sweep"),
id="task-list",
)
# Countdown timer at bottom of sidebar
with Vertical(classes="countdown-container"):
yield Static(
"Next sync: --:--", id="countdown", classes="countdown-text"
)
# Main panel with selected task details
with Vertical(classes="main-panel"):
# Task header with name and progress
with Vertical(classes="task-header"):
yield Static(
"Archive Mail", id="selected-task-name", classes="task-name"
)
with Horizontal(classes="progress-row"):
yield Static("Progress:", id="progress-label")
yield ProgressBar(total=100, id="task-progress")
yield Static("0%", id="progress-percent")
# Log for selected task
with Vertical(classes="log-container"):
yield Static("Activity Log", classes="log-title")
yield Log(id="task-log")
yield Footer()
def on_mount(self) -> None:
"""Initialize the dashboard."""
# Store references to task items
task_list = self.query_one("#task-list", ListView)
for item in task_list.children:
if isinstance(item, TaskListItem):
self._task_items[item.task_id] = item
self._task_logs[item.task_id] = []
# Initialize sync interval
self.sync_interval = self._initial_sync_interval
self.schedule_next_sync()
# Start countdown timer and spinner animation
self._countdown_task = asyncio.create_task(self._update_countdown())
self._spinner_task = asyncio.create_task(self._animate_spinners())
self._log_to_task("archive", "Dashboard initialized. Waiting to start sync...")
self._mounted.set()
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle task selection from the list."""
if isinstance(event.item, TaskListItem):
self.selected_task = event.item.task_id
self._update_main_panel()
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Handle task highlight from the list."""
if isinstance(event.item, TaskListItem):
self.selected_task = event.item.task_id
self._update_main_panel()
def _update_main_panel(self) -> None:
"""Update the main panel to show selected task details."""
task_item = self._task_items.get(self.selected_task)
if not task_item:
return
# Update task name
try:
name_widget = self.query_one("#selected-task-name", Static)
name_widget.update(Text(task_item.task_name, style="bold"))
except Exception:
pass
# Update progress bar
try:
progress_bar = self.query_one("#task-progress", ProgressBar)
progress_bar.total = task_item.total
progress_bar.progress = task_item.progress
percent_widget = self.query_one("#progress-percent", Static)
pct = (
int((task_item.progress / task_item.total) * 100)
if task_item.total > 0
else 0
)
percent_widget.update(f"{pct}%")
except Exception:
pass
# Update log with task-specific logs
try:
log_widget = self.query_one("#task-log", Log)
log_widget.clear()
for entry in self._task_logs.get(self.selected_task, []):
log_widget.write_line(entry)
except Exception:
pass
def _log_to_task(self, task_id: str, message: str, level: str = "INFO") -> None:
"""Add a log entry to a specific task."""
timestamp = datetime.now().strftime("%H:%M:%S")
formatted = f"[{timestamp}] {level}: {message}"
if task_id not in self._task_logs:
self._task_logs[task_id] = []
self._task_logs[task_id].append(formatted)
# If this is the selected task, also write to the visible log
if task_id == self.selected_task:
try:
log_widget = self.query_one("#task-log", Log)
log_widget.write_line(formatted)
except Exception:
pass
def start_task(self, task_id: str, total: int = 100) -> None:
"""Start a task."""
if task_id in self._task_items:
item = self._task_items[task_id]
item.status = TaskStatus.RUNNING
item.progress = 0
item.total = total
item.update_display()
self._log_to_task(task_id, f"Starting {item.task_name}...")
if task_id == self.selected_task:
self._update_main_panel()
def update_task(self, task_id: str, progress: int, message: str = "") -> None:
"""Update task progress."""
if task_id in self._task_items:
item = self._task_items[task_id]
item.progress = progress
item.update_display()
if message:
self._log_to_task(task_id, message)
if task_id == self.selected_task:
self._update_main_panel()
def complete_task(self, task_id: str, message: str = "") -> None:
"""Mark a task as complete."""
if task_id in self._task_items:
item = self._task_items[task_id]
item.status = TaskStatus.COMPLETED
item.progress = item.total
item.update_display()
self._log_to_task(
task_id,
f"Completed: {message}" if message else "Completed successfully",
)
if task_id == self.selected_task:
self._update_main_panel()
def error_task(self, task_id: str, error: str) -> None:
"""Mark a task as errored."""
if task_id in self._task_items:
item = self._task_items[task_id]
item.status = TaskStatus.ERROR
item.update_display()
self._log_to_task(task_id, f"ERROR: {error}", "ERROR")
if task_id == self.selected_task:
self._update_main_panel()
def skip_task(self, task_id: str, reason: str = "") -> None:
"""Mark a task as skipped (completed with no work)."""
if task_id in self._task_items:
item = self._task_items[task_id]
item.status = TaskStatus.COMPLETED
item.update_display()
self._log_to_task(task_id, f"Skipped: {reason}" if reason else "Skipped")
if task_id == self.selected_task:
self._update_main_panel()
def action_refresh(self) -> None:
"""Refresh the dashboard."""
self._update_main_panel()
def action_cursor_up(self) -> None:
"""Move cursor up in task list."""
task_list = self.query_one("#task-list", ListView)
task_list.action_cursor_up()
def action_cursor_down(self) -> None:
"""Move cursor down in task list."""
task_list = self.query_one("#task-list", ListView)
task_list.action_cursor_down()
def action_sync_now(self) -> None:
"""Trigger an immediate sync."""
if self._sync_callback:
asyncio.create_task(self._run_sync_callback())
else:
self._log_to_task("archive", "No sync callback configured")
async def _run_sync_callback(self) -> None:
"""Run the sync callback if set."""
if self._sync_callback:
if asyncio.iscoroutinefunction(self._sync_callback):
await self._sync_callback()
else:
self._sync_callback()
def action_increase_interval(self) -> None:
"""Increase sync interval by 1 minute."""
self.sync_interval = min(self.sync_interval + 60, 3600) # Max 1 hour
self._update_countdown_display()
self._log_to_task(
self.selected_task,
f"Sync interval: {self.sync_interval // 60} min",
)
def action_decrease_interval(self) -> None:
"""Decrease sync interval by 1 minute."""
self.sync_interval = max(self.sync_interval - 60, 60) # Min 1 minute
self._update_countdown_display()
self._log_to_task(
self.selected_task,
f"Sync interval: {self.sync_interval // 60} min",
)
def set_sync_callback(self, callback: Callable) -> None:
"""Set the callback to run when sync is triggered."""
self._sync_callback = callback
def schedule_next_sync(self) -> None:
"""Schedule the next sync time."""
import time
self.next_sync_time = time.time() + self.sync_interval
def reset_all_tasks(self) -> None:
"""Reset all tasks to pending state."""
for task_id, item in self._task_items.items():
item.status = TaskStatus.PENDING
item.progress = 0
item.update_display()
self._update_main_panel()
async def _update_countdown(self) -> None:
"""Update the countdown timer every second."""
import time
while True:
try:
self._update_countdown_display()
await asyncio.sleep(1)
except asyncio.CancelledError:
break
except Exception:
await asyncio.sleep(1)
def _update_countdown_display(self) -> None:
"""Update the countdown display widget."""
import time
try:
countdown_widget = self.query_one("#countdown", Static)
remaining = max(0, self.next_sync_time - time.time())
if remaining <= 0:
countdown_widget.update(f"Syncing... ({self.sync_interval // 60}m)")
else:
minutes = int(remaining // 60)
seconds = int(remaining % 60)
countdown_widget.update(
f"Next: {minutes:02d}:{seconds:02d} ({self.sync_interval // 60}m)"
)
except Exception:
pass
async def _animate_spinners(self) -> None:
"""Animate spinners for running tasks."""
while True:
try:
# Update all running task spinners
for task_id, item in self._task_items.items():
if item.status == TaskStatus.RUNNING:
item.advance_spinner()
item.update_display()
await asyncio.sleep(0.08) # ~12 FPS for smooth animation
except asyncio.CancelledError:
break
except Exception:
await asyncio.sleep(0.08)
class SyncProgressTracker:
"""Track sync progress and update the dashboard."""
def __init__(self, dashboard: SyncDashboard):
self.dashboard = dashboard
def start_task(self, task_id: str, total: int = 100) -> None:
"""Start tracking a task."""
self.dashboard.start_task(task_id, total)
def update_task(self, task_id: str, progress: int, message: str = "") -> None:
"""Update task progress."""
self.dashboard.update_task(task_id, progress, message)
def complete_task(self, task_id: str, message: str = "") -> None:
"""Mark a task as complete."""
self.dashboard.complete_task(task_id, message)
def error_task(self, task_id: str, error: str) -> None:
"""Mark a task as failed."""
self.dashboard.error_task(task_id, error)
def skip_task(self, task_id: str, reason: str = "") -> None:
"""Mark a task as skipped."""
self.dashboard.skip_task(task_id, reason)
# Global dashboard instance
_dashboard_instance: Optional[SyncDashboard] = None
_progress_tracker: Optional[SyncProgressTracker] = None
def get_dashboard() -> Optional[SyncDashboard]:
"""Get the global dashboard instance."""
global _dashboard_instance
return _dashboard_instance
def get_progress_tracker() -> Optional[SyncProgressTracker]:
"""Get the global progress_tracker"""
global _progress_tracker
return _progress_tracker
async def run_dashboard_sync():
"""Run sync with dashboard UI."""
global _dashboard_instance, _progress_tracker
dashboard = SyncDashboard()
tracker = SyncProgressTracker(dashboard)
_dashboard_instance = dashboard
_progress_tracker = tracker
async def do_sync():
"""Run the actual sync process."""
try:
# Reset all tasks before starting
dashboard.reset_all_tasks()
# Simulate sync progress for demo (replace with actual sync calls)
# Stage 1: Sync local changes to server
# Archive mail
tracker.start_task("archive", 100)
tracker.update_task("archive", 50, "Scanning for archived messages...")
await asyncio.sleep(0.3)
tracker.update_task("archive", 100, "Moving 3 messages to archive...")
await asyncio.sleep(0.2)
tracker.complete_task("archive", "3 messages archived")
# Outbox
tracker.start_task("outbox", 100)
tracker.update_task("outbox", 50, "Checking outbox...")
await asyncio.sleep(0.2)
tracker.complete_task("outbox", "No pending emails")
# Stage 2: Fetch from server
# Inbox sync
tracker.start_task("inbox", 100)
for i in range(0, 101, 20):
tracker.update_task("inbox", i, f"Fetching emails... {i}%")
await asyncio.sleep(0.3)
tracker.complete_task("inbox", "150 emails processed")
# Calendar sync
tracker.start_task("calendar", 100)
for i in range(0, 101, 25):
tracker.update_task("calendar", i, f"Syncing events... {i}%")
await asyncio.sleep(0.3)
tracker.complete_task("calendar", "25 events synced")
# Stage 3: Task management
# Godspeed sync
tracker.start_task("godspeed", 100)
for i in range(0, 101, 33):
tracker.update_task(
"godspeed", min(i, 100), f"Syncing tasks... {min(i, 100)}%"
)
await asyncio.sleep(0.3)
tracker.complete_task("godspeed", "42 tasks synced")
# Task sweep
tracker.start_task("sweep")
tracker.update_task("sweep", 50, "Scanning notes directory...")
await asyncio.sleep(0.2)
tracker.skip_task("sweep", "Before 6 PM, skipping daily sweep")
# Schedule next sync
dashboard.schedule_next_sync()
except Exception as e:
tracker.error_task("archive", str(e))
# Set the sync callback so 's' key triggers it
dashboard.set_sync_callback(do_sync)
async def sync_loop():
"""Run sync on interval."""
import time
# Wait for the dashboard to be mounted before updating widgets
await dashboard._mounted.wait()
# Run initial sync
await do_sync()
# Then loop waiting for next sync time
while True:
try:
remaining = dashboard.next_sync_time - time.time()
if remaining <= 0:
await do_sync()
else:
await asyncio.sleep(1)
except asyncio.CancelledError:
break
except Exception:
await asyncio.sleep(1)
# Run dashboard and sync loop concurrently
await asyncio.gather(dashboard.run_async(), sync_loop())

View File

@@ -9,7 +9,7 @@ class GitLabMonitorConfig:
def __init__(self, config_path: Optional[str] = None): def __init__(self, config_path: Optional[str] = None):
self.config_path = config_path or os.path.expanduser( self.config_path = config_path or os.path.expanduser(
"~/.config/gtd-tools/gitlab_monitor.yaml" "~/.config/luk/gitlab_monitor.yaml"
) )
self.config = self._load_config() self.config = self._load_config()
@@ -56,9 +56,7 @@ class GitLabMonitorConfig:
}, },
"logging": { "logging": {
"level": "INFO", "level": "INFO",
"log_file": os.path.expanduser( "log_file": os.path.expanduser("~/.local/share/luk/gitlab_monitor.log"),
"~/.config/gtd-tools/gitlab_monitor.log"
),
}, },
} }

View File

@@ -15,7 +15,12 @@ class GodspeedClient:
BASE_URL = "https://api.godspeedapp.com" BASE_URL = "https://api.godspeedapp.com"
def __init__(self, email: str = None, password: str = None, token: str = None): def __init__(
self,
email: Optional[str] = None,
password: Optional[str] = None,
token: Optional[str] = None,
):
self.email = email self.email = email
self.password = password self.password = password
self.token = token self.token = token
@@ -60,7 +65,9 @@ class GodspeedClient:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def get_tasks(self, list_id: str = None, status: str = None) -> Dict[str, Any]: def get_tasks(
self, list_id: Optional[str] = None, status: Optional[str] = None
) -> Dict[str, Any]:
"""Get tasks with optional filtering.""" """Get tasks with optional filtering."""
params = {} params = {}
if list_id: if list_id:
@@ -81,8 +88,8 @@ class GodspeedClient:
def create_task( def create_task(
self, self,
title: str, title: str,
list_id: str = None, list_id: Optional[str] = None,
notes: str = None, notes: Optional[str] = None,
location: str = "end", location: str = "end",
**kwargs, **kwargs,
) -> Dict[str, Any]: ) -> Dict[str, Any]:

View File

@@ -63,9 +63,22 @@ def get_access_token(scopes):
) )
accounts = app.get_accounts() accounts = app.get_accounts()
token_response = None
# Try silent authentication first
if accounts: if accounts:
token_response = app.acquire_token_silent(scopes, account=accounts[0]) token_response = app.acquire_token_silent(scopes, account=accounts[0])
else:
# If silent auth failed or no accounts, clear cache and do device flow
if not token_response or "access_token" not in token_response:
# Clear the cache to force fresh authentication
if os.path.exists(cache_file):
os.remove(cache_file)
cache = msal.SerializableTokenCache() # Create new empty cache
app = msal.PublicClientApplication(
client_id, authority=authority, token_cache=cache
)
flow = app.initiate_device_flow(scopes=scopes) flow = app.initiate_device_flow(scopes=scopes)
if "user_code" not in flow: if "user_code" not in flow:
raise Exception("Failed to create device flow") raise Exception("Failed to create device flow")

View File

@@ -18,16 +18,50 @@ semaphore = asyncio.Semaphore(2)
async def _handle_throttling_retry(func, *args, max_retries=3): async def _handle_throttling_retry(func, *args, max_retries=3):
"""Handle 429 throttling with exponential backoff retry.""" """Handle 429 throttling and 401 authentication errors with exponential backoff retry."""
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
return await func(*args) return await func(*args)
except Exception as e: except Exception as e:
if "429" in str(e) and attempt < max_retries - 1: error_str = str(e)
if (
"429" in error_str
or "InvalidAuthenticationToken" in error_str
or "401" in error_str
) and attempt < max_retries - 1:
wait_time = (2**attempt) + 1 # Exponential backoff: 2, 5, 9 seconds wait_time = (2**attempt) + 1 # Exponential backoff: 2, 5, 9 seconds
if "429" in error_str:
print( print(
f"Rate limited, waiting {wait_time}s before retry {attempt + 1}/{max_retries}" f"Rate limited, waiting {wait_time}s before retry {attempt + 1}/{max_retries}"
) )
elif "InvalidAuthenticationToken" in error_str or "401" in error_str:
print(
f"Authentication failed (token expired), refreshing token and retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
# Force re-authentication by clearing cache and getting new token
import os
cache_file = "token_cache.bin"
if os.path.exists(cache_file):
os.remove(cache_file)
# Re-import and call get_access_token to refresh
from src.services.microsoft_graph.auth import get_access_token
# We need to get the scopes from somewhere - for now assume standard scopes
scopes = [
"https://graph.microsoft.com/Calendars.Read",
"https://graph.microsoft.com/Mail.ReadWrite",
]
try:
new_token, new_headers = get_access_token(scopes)
# Update the headers in args - this is a bit hacky but should work
if len(args) > 1 and isinstance(args[1], dict):
args = list(args)
args[1] = new_headers
args = tuple(args)
except Exception as auth_error:
print(f"Failed to refresh token: {auth_error}")
raise e # Re-raise original error
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
continue continue
raise e raise e
@@ -55,10 +89,11 @@ async def _fetch_impl(url, headers):
async with semaphore: async with semaphore:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response: async with session.get(url, headers=headers) as response:
if response.status == 429: if response.status in [401, 429]:
# Let the retry handler deal with throttling # Let the retry handler deal with authentication and throttling
response_text = await response.text()
raise Exception( raise Exception(
f"Failed to fetch {url}: {response.status} {await response.text()}" f"Failed to fetch {url}: {response.status} {response_text}"
) )
elif response.status != 200: elif response.status != 200:
raise Exception( raise Exception(
@@ -92,9 +127,10 @@ async def _post_impl(url, headers, json_data):
async with semaphore: async with semaphore:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=json_data) as response: async with session.post(url, headers=headers, json=json_data) as response:
if response.status == 429: if response.status in [401, 429]:
response_text = await response.text()
raise Exception( raise Exception(
f"Failed to post {url}: {response.status} {await response.text()}" f"Failed to post {url}: {response.status} {response_text}"
) )
return response.status return response.status
@@ -119,9 +155,10 @@ async def _patch_impl(url, headers, json_data):
async with semaphore: async with semaphore:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.patch(url, headers=headers, json=json_data) as response: async with session.patch(url, headers=headers, json=json_data) as response:
if response.status == 429: if response.status in [401, 429]:
response_text = await response.text()
raise Exception( raise Exception(
f"Failed to patch {url}: {response.status} {await response.text()}" f"Failed to patch {url}: {response.status} {response_text}"
) )
return response.status return response.status
@@ -145,9 +182,10 @@ async def _delete_impl(url, headers):
async with semaphore: async with semaphore:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.delete(url, headers=headers) as response: async with session.delete(url, headers=headers) as response:
if response.status == 429: if response.status in [401, 429]:
response_text = await response.text()
raise Exception( raise Exception(
f"Failed to delete {url}: {response.status} {await response.text()}" f"Failed to delete {url}: {response.status} {response_text}"
) )
return response.status return response.status
@@ -176,9 +214,10 @@ async def _batch_impl(requests, headers):
async with session.post( async with session.post(
batch_url, headers=headers, json=batch_data batch_url, headers=headers, json=batch_data
) as response: ) as response:
if response.status == 429: if response.status in [401, 429]:
response_text = await response.text()
raise Exception( raise Exception(
f"Batch request failed: {response.status} {await response.text()}" f"Batch request failed: {response.status} {response_text}"
) )
elif response.status != 200: elif response.status != 200:
raise Exception( raise Exception(

352
src/utils/platform.py Normal file
View File

@@ -0,0 +1,352 @@
"""Cross-platform compatibility utilities."""
import os
import sys
import platform
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any
def get_platform_info() -> Dict[str, str]:
"""Get platform information for compatibility checks."""
return {
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
"processor": platform.processor(),
"python_version": platform.python_version(),
"python_implementation": platform.python_implementation(),
}
def is_supported_platform() -> bool:
"""Check if the current platform is supported."""
system = platform.system()
python_version = tuple(map(int, platform.python_version().split(".")))
# Check Python version
if python_version < (3, 12):
return False
# Check operating system
supported_systems = ["Darwin", "Linux", "Windows"]
return system in supported_systems
def get_default_config_dir() -> Path:
"""Get platform-specific config directory."""
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / "Library" / "Application Support" / "luk"
elif system == "Linux":
config_dir = os.environ.get("XDG_CONFIG_HOME")
if config_dir:
return Path(config_dir) / "luk"
return Path.home() / ".config" / "luk"
elif system == "Windows":
return Path(os.environ.get("APPDATA", "")) / "luk"
else:
# Fallback to ~/.config
return Path.home() / ".config" / "luk"
def get_default_data_dir() -> Path:
"""Get platform-specific data directory."""
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / "Library" / "Application Support" / "luk"
elif system == "Linux":
data_dir = os.environ.get("XDG_DATA_HOME")
if data_dir:
return Path(data_dir) / "luk"
return Path.home() / ".local" / "share" / "luk"
elif system == "Windows":
return Path(os.environ.get("LOCALAPPDATA", "")) / "luk"
else:
# Fallback to ~/.local/share
return Path.home() / ".local" / "share" / "luk"
def get_default_log_dir() -> Path:
"""Get platform-specific log directory."""
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / "Library" / "Logs" / "luk"
elif system == "Linux":
data_dir = os.environ.get("XDG_DATA_HOME")
if data_dir:
return Path(data_dir) / "luk" / "logs"
return Path.home() / ".local" / "share" / "luk" / "logs"
elif system == "Windows":
return Path(os.environ.get("LOCALAPPDATA", "")) / "luk" / "logs"
else:
# Fallback to ~/.local/share/logs
return Path.home() / ".local" / "share" / "luk" / "logs"
def get_default_maildir_path() -> Path:
"""Get platform-specific default Maildir path."""
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / "Library" / "Mail"
elif system == "Linux":
return Path.home() / "Mail"
elif system == "Windows":
return Path.home() / "Mail"
else:
return Path.home() / "Mail"
def check_dependencies() -> Dict[str, bool]:
"""Check if required system dependencies are available."""
dependencies = {
"python": True, # We're running Python
"pip": False,
"git": False,
"curl": False,
"wget": False,
}
# Check for pip
try:
subprocess.run(["pip", "--version"], capture_output=True, check=True)
dependencies["pip"] = True
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# Check for git
try:
subprocess.run(["git", "--version"], capture_output=True, check=True)
dependencies["git"] = True
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# Check for curl
try:
subprocess.run(["curl", "--version"], capture_output=True, check=True)
dependencies["curl"] = True
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# Check for wget
try:
subprocess.run(["wget", "--version"], capture_output=True, check=True)
dependencies["wget"] = True
except (subprocess.CalledProcessError, FileNotFoundError):
pass
return dependencies
def get_shell_info() -> Dict[str, str]:
"""Get shell information for completion setup."""
shell = os.environ.get("SHELL", "")
shell_name = Path(shell).name if shell else "unknown"
return {
"shell_path": shell,
"shell_name": shell_name,
"config_file": get_shell_config_file(shell_name),
}
def get_shell_config_file(shell_name: str) -> str:
"""Get the config file for a given shell."""
shell_configs = {
"bash": "~/.bashrc",
"zsh": "~/.zshrc",
"fish": "~/.config/fish/config.fish",
"ksh": "~/.kshrc",
"csh": "~/.cshrc",
"tcsh": "~/.tcshrc",
}
return shell_configs.get(shell_name, "~/.profile")
def setup_platform_specific() -> None:
"""Setup platform-specific configurations."""
system = platform.system()
if system == "Darwin":
setup_macos()
elif system == "Linux":
setup_linux()
elif system == "Windows":
setup_windows()
def setup_macos() -> None:
"""Setup macOS-specific configurations."""
# Ensure macOS-specific directories exist
config_dir = get_default_config_dir()
data_dir = get_default_data_dir()
log_dir = get_default_log_dir()
for directory in [config_dir, data_dir, log_dir]:
directory.mkdir(parents=True, exist_ok=True)
def setup_linux() -> None:
"""Setup Linux-specific configurations."""
# Ensure XDG directories exist
config_dir = get_default_config_dir()
data_dir = get_default_data_dir()
log_dir = get_default_log_dir()
for directory in [config_dir, data_dir, log_dir]:
directory.mkdir(parents=True, exist_ok=True)
def setup_windows() -> None:
"""Setup Windows-specific configurations."""
# Ensure Windows-specific directories exist
config_dir = get_default_config_dir()
data_dir = get_default_data_dir()
log_dir = get_default_log_dir()
for directory in [config_dir, data_dir, log_dir]:
directory.mkdir(parents=True, exist_ok=True)
def get_platform_specific_commands() -> Dict[str, str]:
"""Get platform-specific command equivalents."""
system = platform.system()
if system == "Darwin" or system == "Linux":
return {
"open": "open" if system == "Darwin" else "xdg-open",
"copy": "pbcopy" if system == "Darwin" else "xclip -selection clipboard",
"notify": "osascript -e 'display notification \"%s\"'"
if system == "Darwin"
else 'notify-send "%s"',
}
elif system == "Windows":
return {
"open": "start",
"copy": "clip",
"notify": "powershell -Command \"Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('%s')\"",
}
else:
return {}
def check_terminal_compatibility() -> Dict[str, bool]:
"""Check terminal compatibility for TUI features."""
return {
"color_support": sys.stdout.isatty(),
"unicode_support": True, # Most modern terminals support Unicode
"mouse_support": check_mouse_support(),
"textual_support": check_textual_support(),
}
def check_mouse_support() -> bool:
"""Check if terminal supports mouse events."""
# This is a basic check - actual mouse support depends on the terminal
return sys.stdout.isatty()
def check_textual_support() -> bool:
"""Check if Textual TUI framework can run."""
try:
import textual
return True
except ImportError:
return False
def get_platform_recommendations() -> list[str]:
"""Get platform-specific recommendations."""
system = platform.system()
recommendations = []
if system == "Darwin":
recommendations.extend(
[
"Install iTerm2 or Terminal.app for best TUI experience",
"Enable 'Terminal > Preferences > Profiles > Text > Unicode Normalization Form' set to 'None'",
"Consider using Homebrew for package management: brew install python3",
]
)
elif system == "Linux":
recommendations.extend(
[
"Use a modern terminal emulator like GNOME Terminal, Konsole, or Alacritty",
"Ensure UTF-8 locale is set: export LANG=en_US.UTF-8",
"Install system packages: sudo apt-get install python3-pip python3-venv",
]
)
elif system == "Windows":
recommendations.extend(
[
"Use Windows Terminal for best TUI experience",
"Enable UTF-8 support in Windows Terminal settings",
"Consider using WSL2 for better Unix compatibility",
"Install Python from python.org or Microsoft Store",
]
)
return recommendations
def validate_environment() -> Dict[str, Any]:
"""Validate the current environment for compatibility."""
platform_info = get_platform_info()
dependencies = check_dependencies()
terminal_compat = check_terminal_compatibility()
return {
"platform_supported": is_supported_platform(),
"platform_info": platform_info,
"dependencies": dependencies,
"terminal_compatibility": terminal_compat,
"recommendations": get_platform_recommendations(),
"config_dir": str(get_default_config_dir()),
"data_dir": str(get_default_data_dir()),
"log_dir": str(get_default_log_dir()),
}
if __name__ == "__main__":
# Print environment validation
env_info = validate_environment()
print("Platform Compatibility Check")
print("=" * 40)
print(
f"Platform: {env_info['platform_info']['system']} {env_info['platform_info']['release']}"
)
print(
f"Python: {env_info['platform_info']['python_version']} ({env_info['platform_info']['python_implementation']})"
)
print(f"Supported: {'' if env_info['platform_supported'] else ''}")
print()
print("Dependencies:")
for dep, available in env_info["dependencies"].items():
print(f" {dep}: {'' if available else ''}")
print()
print("Terminal Compatibility:")
for feature, supported in env_info["terminal_compatibility"].items():
print(f" {feature}: {'' if supported else ''}")
print()
print("Directories:")
print(f" Config: {env_info['config_dir']}")
print(f" Data: {env_info['data_dir']}")
print(f" Logs: {env_info['log_dir']}")
print()
if env_info["recommendations"]:
print("Recommendations:")
for rec in env_info["recommendations"]:
print(f"{rec}")

400
tests/test_platform.py Normal file
View File

@@ -0,0 +1,400 @@
"""Tests for platform compatibility utilities."""
import pytest
import os
import sys
import platform
from pathlib import Path
from unittest.mock import patch, MagicMock
from src.utils.platform import (
get_platform_info,
is_supported_platform,
get_default_config_dir,
get_default_data_dir,
get_default_log_dir,
get_default_maildir_path,
check_dependencies,
get_shell_info,
get_shell_config_file,
get_platform_specific_commands,
check_terminal_compatibility,
check_textual_support,
get_platform_recommendations,
validate_environment,
)
class TestGetPlatformInfo:
"""Tests for get_platform_info function."""
def test_returns_dict(self):
"""Test that get_platform_info returns a dictionary."""
info = get_platform_info()
assert isinstance(info, dict)
def test_contains_required_keys(self):
"""Test that info contains all required keys."""
info = get_platform_info()
required_keys = [
"system",
"release",
"version",
"machine",
"processor",
"python_version",
"python_implementation",
]
for key in required_keys:
assert key in info
def test_values_are_strings(self):
"""Test that all values are strings."""
info = get_platform_info()
for value in info.values():
assert isinstance(value, str)
class TestIsSupportedPlatform:
"""Tests for is_supported_platform function."""
def test_current_platform_supported(self):
"""Test that current platform is supported (if Python 3.12+)."""
python_version = tuple(map(int, platform.python_version().split(".")))
if python_version >= (3, 12):
assert is_supported_platform() is True
@patch("src.utils.platform.platform.python_version")
def test_old_python_not_supported(self, mock_version):
"""Test that old Python versions are not supported."""
mock_version.return_value = "3.10.0"
assert is_supported_platform() is False
@patch("src.utils.platform.platform.system")
@patch("src.utils.platform.platform.python_version")
def test_supported_systems(self, mock_version, mock_system):
"""Test that Darwin, Linux, Windows are supported."""
mock_version.return_value = "3.12.0"
for system in ["Darwin", "Linux", "Windows"]:
mock_system.return_value = system
assert is_supported_platform() is True
@patch("src.utils.platform.platform.system")
@patch("src.utils.platform.platform.python_version")
def test_unsupported_system(self, mock_version, mock_system):
"""Test that unknown systems are not supported."""
mock_version.return_value = "3.12.0"
mock_system.return_value = "Unknown"
assert is_supported_platform() is False
class TestGetDefaultDirectories:
"""Tests for default directory functions."""
def test_config_dir_returns_path(self):
"""Test that get_default_config_dir returns a Path."""
result = get_default_config_dir()
assert isinstance(result, Path)
def test_data_dir_returns_path(self):
"""Test that get_default_data_dir returns a Path."""
result = get_default_data_dir()
assert isinstance(result, Path)
def test_log_dir_returns_path(self):
"""Test that get_default_log_dir returns a Path."""
result = get_default_log_dir()
assert isinstance(result, Path)
def test_maildir_path_returns_path(self):
"""Test that get_default_maildir_path returns a Path."""
result = get_default_maildir_path()
assert isinstance(result, Path)
@patch("src.utils.platform.platform.system")
def test_macos_config_dir(self, mock_system):
"""Test macOS config directory."""
mock_system.return_value = "Darwin"
result = get_default_config_dir()
assert "Library" in str(result)
assert "Application Support" in str(result)
@patch("src.utils.platform.platform.system")
def test_linux_config_dir_default(self, mock_system):
"""Test Linux config directory (default)."""
mock_system.return_value = "Linux"
with patch.dict(os.environ, {}, clear=True):
result = get_default_config_dir()
assert ".config" in str(result)
@patch("src.utils.platform.platform.system")
def test_linux_config_dir_xdg(self, mock_system):
"""Test Linux config directory with XDG_CONFIG_HOME."""
mock_system.return_value = "Linux"
with patch.dict(os.environ, {"XDG_CONFIG_HOME": "/custom/config"}):
result = get_default_config_dir()
assert "/custom/config" in str(result)
@patch("src.utils.platform.platform.system")
def test_windows_config_dir(self, mock_system):
"""Test Windows config directory."""
mock_system.return_value = "Windows"
with patch.dict(os.environ, {"APPDATA": "C:\\Users\\test\\AppData\\Roaming"}):
result = get_default_config_dir()
assert "luk" in str(result)
class TestCheckDependencies:
"""Tests for check_dependencies function."""
def test_returns_dict(self):
"""Test that check_dependencies returns a dictionary."""
result = check_dependencies()
assert isinstance(result, dict)
def test_python_always_available(self):
"""Test that Python is always marked as available."""
result = check_dependencies()
assert result["python"] is True
def test_contains_expected_keys(self):
"""Test that result contains expected dependency keys."""
result = check_dependencies()
expected_keys = ["python", "pip", "git", "curl", "wget"]
for key in expected_keys:
assert key in result
def test_values_are_bool(self):
"""Test that all values are boolean."""
result = check_dependencies()
for value in result.values():
assert isinstance(value, bool)
class TestGetShellInfo:
"""Tests for shell info functions."""
def test_get_shell_info_returns_dict(self):
"""Test that get_shell_info returns a dictionary."""
result = get_shell_info()
assert isinstance(result, dict)
def test_get_shell_info_keys(self):
"""Test that result has expected keys."""
result = get_shell_info()
assert "shell_path" in result
assert "shell_name" in result
assert "config_file" in result
def test_get_shell_config_file_bash(self):
"""Test shell config file for bash."""
result = get_shell_config_file("bash")
assert result == "~/.bashrc"
def test_get_shell_config_file_zsh(self):
"""Test shell config file for zsh."""
result = get_shell_config_file("zsh")
assert result == "~/.zshrc"
def test_get_shell_config_file_fish(self):
"""Test shell config file for fish."""
result = get_shell_config_file("fish")
assert result == "~/.config/fish/config.fish"
def test_get_shell_config_file_unknown(self):
"""Test shell config file for unknown shell."""
result = get_shell_config_file("unknown")
assert result == "~/.profile"
class TestGetPlatformSpecificCommands:
"""Tests for get_platform_specific_commands function."""
@patch("src.utils.platform.platform.system")
def test_macos_commands(self, mock_system):
"""Test macOS-specific commands."""
mock_system.return_value = "Darwin"
result = get_platform_specific_commands()
assert result["open"] == "open"
assert result["copy"] == "pbcopy"
@patch("src.utils.platform.platform.system")
def test_linux_commands(self, mock_system):
"""Test Linux-specific commands."""
mock_system.return_value = "Linux"
result = get_platform_specific_commands()
assert result["open"] == "xdg-open"
assert "xclip" in result["copy"]
@patch("src.utils.platform.platform.system")
def test_windows_commands(self, mock_system):
"""Test Windows-specific commands."""
mock_system.return_value = "Windows"
result = get_platform_specific_commands()
assert result["open"] == "start"
assert result["copy"] == "clip"
@patch("src.utils.platform.platform.system")
def test_unknown_system_commands(self, mock_system):
"""Test unknown system returns empty dict."""
mock_system.return_value = "Unknown"
result = get_platform_specific_commands()
assert result == {}
class TestCheckTerminalCompatibility:
"""Tests for terminal compatibility functions."""
def test_returns_dict(self):
"""Test that check_terminal_compatibility returns a dictionary."""
result = check_terminal_compatibility()
assert isinstance(result, dict)
def test_contains_expected_keys(self):
"""Test that result contains expected keys."""
result = check_terminal_compatibility()
expected_keys = [
"color_support",
"unicode_support",
"mouse_support",
"textual_support",
]
for key in expected_keys:
assert key in result
def test_values_are_bool(self):
"""Test that all values are boolean."""
result = check_terminal_compatibility()
for value in result.values():
assert isinstance(value, bool)
def test_check_textual_support(self):
"""Test check_textual_support."""
# Textual should be available in our test environment
result = check_textual_support()
assert isinstance(result, bool)
class TestGetPlatformRecommendations:
"""Tests for get_platform_recommendations function."""
def test_returns_list(self):
"""Test that get_platform_recommendations returns a list."""
result = get_platform_recommendations()
assert isinstance(result, list)
@patch("src.utils.platform.platform.system")
def test_macos_recommendations(self, mock_system):
"""Test macOS recommendations."""
mock_system.return_value = "Darwin"
result = get_platform_recommendations()
assert len(result) > 0
# Check for macOS-specific content
assert any("iTerm2" in r or "Terminal.app" in r for r in result)
@patch("src.utils.platform.platform.system")
def test_linux_recommendations(self, mock_system):
"""Test Linux recommendations."""
mock_system.return_value = "Linux"
result = get_platform_recommendations()
assert len(result) > 0
# Check for Linux-specific content
assert any("UTF-8" in r or "GNOME" in r for r in result)
@patch("src.utils.platform.platform.system")
def test_windows_recommendations(self, mock_system):
"""Test Windows recommendations."""
mock_system.return_value = "Windows"
result = get_platform_recommendations()
assert len(result) > 0
# Check for Windows-specific content
assert any("Windows Terminal" in r or "WSL" in r for r in result)
class TestValidateEnvironment:
"""Tests for validate_environment function."""
def test_returns_dict(self):
"""Test that validate_environment returns a dictionary."""
result = validate_environment()
assert isinstance(result, dict)
def test_contains_required_keys(self):
"""Test that result contains required keys."""
result = validate_environment()
required_keys = [
"platform_supported",
"platform_info",
"dependencies",
"terminal_compatibility",
"recommendations",
"config_dir",
"data_dir",
"log_dir",
]
for key in required_keys:
assert key in result
def test_platform_supported_is_bool(self):
"""Test that platform_supported is boolean."""
result = validate_environment()
assert isinstance(result["platform_supported"], bool)
def test_platform_info_is_dict(self):
"""Test that platform_info is a dictionary."""
result = validate_environment()
assert isinstance(result["platform_info"], dict)
def test_dependencies_is_dict(self):
"""Test that dependencies is a dictionary."""
result = validate_environment()
assert isinstance(result["dependencies"], dict)
def test_recommendations_is_list(self):
"""Test that recommendations is a list."""
result = validate_environment()
assert isinstance(result["recommendations"], list)
def test_directory_paths_are_strings(self):
"""Test that directory paths are strings."""
result = validate_environment()
assert isinstance(result["config_dir"], str)
assert isinstance(result["data_dir"], str)
assert isinstance(result["log_dir"], str)

239
tests/test_sync_daemon.py Normal file
View File

@@ -0,0 +1,239 @@
"""Tests for the sync daemon."""
import pytest
import os
import tempfile
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch, AsyncMock
from src.cli.sync_daemon import (
SyncDaemon,
create_daemon_config,
)
class TestCreateDaemonConfig:
"""Tests for create_daemon_config function."""
def test_default_config(self):
"""Test default configuration values."""
config = create_daemon_config()
assert config["dry_run"] is False
assert config["vdir"] == "~/Calendar"
assert config["icsfile"] is None
assert config["org"] == "corteva"
assert config["days_back"] == 1
assert config["days_forward"] == 30
assert config["continue_iteration"] is False
assert config["download_attachments"] is False
assert config["two_way_calendar"] is False
assert config["notify"] is False
assert config["sync_interval"] == 300
assert config["check_interval"] == 10
def test_custom_config(self):
"""Test custom configuration values."""
config = create_daemon_config(
dry_run=True,
org="mycompany",
vdir="~/MyCalendar",
notify=True,
sync_interval=600,
)
assert config["dry_run"] is True
assert config["org"] == "mycompany"
assert config["vdir"] == "~/MyCalendar"
assert config["notify"] is True
assert config["sync_interval"] == 600
def test_pid_file_default(self):
"""Test default PID file path."""
config = create_daemon_config()
assert "luk.pid" in config["pid_file"]
def test_log_file_default(self):
"""Test default log file path."""
config = create_daemon_config()
assert "luk.log" in config["log_file"]
class TestSyncDaemon:
"""Tests for the SyncDaemon class."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def daemon_config(self, temp_dir):
"""Create a daemon config with temp paths."""
return create_daemon_config(
pid_file=str(temp_dir / "test_daemon.pid"),
log_file=str(temp_dir / "test_daemon.log"),
)
def test_init(self, daemon_config):
"""Test daemon initialization."""
daemon = SyncDaemon(daemon_config)
assert daemon.config == daemon_config
assert daemon.running is False
assert daemon.sync_interval == 300
assert daemon.check_interval == 10
def test_init_custom_intervals(self, temp_dir):
"""Test daemon with custom intervals."""
config = create_daemon_config(
pid_file=str(temp_dir / "test_daemon.pid"),
log_file=str(temp_dir / "test_daemon.log"),
sync_interval=600,
check_interval=30,
)
daemon = SyncDaemon(config)
assert daemon.sync_interval == 600
assert daemon.check_interval == 30
def test_setup_logging(self, daemon_config, temp_dir):
"""Test logging setup."""
daemon = SyncDaemon(daemon_config)
# Logger should be configured
assert daemon.logger is not None
assert daemon.logger.level == logging.INFO
# Log directory should be created
log_file = temp_dir / "test_daemon.log"
assert log_file.parent.exists()
def test_is_running_no_pid_file(self, daemon_config):
"""Test is_running when no PID file exists."""
daemon = SyncDaemon(daemon_config)
assert daemon.is_running() is False
def test_is_running_stale_pid_file(self, daemon_config, temp_dir):
"""Test is_running with stale PID file."""
daemon = SyncDaemon(daemon_config)
# Create a PID file with non-existent process
pid_file = temp_dir / "test_daemon.pid"
pid_file.write_text("99999999") # Very unlikely to exist
# Should detect stale PID and clean up
assert daemon.is_running() is False
assert not pid_file.exists()
def test_get_pid(self, daemon_config, temp_dir):
"""Test get_pid reads PID correctly."""
daemon = SyncDaemon(daemon_config)
# Create a PID file
pid_file = temp_dir / "test_daemon.pid"
pid_file.write_text("12345")
assert daemon.get_pid() == 12345
def test_stop_not_running(self, daemon_config, capsys):
"""Test stop when daemon is not running."""
daemon = SyncDaemon(daemon_config)
daemon.stop()
captured = capsys.readouterr()
assert "not running" in captured.out.lower()
def test_status_not_running(self, daemon_config, capsys):
"""Test status when daemon is not running."""
daemon = SyncDaemon(daemon_config)
daemon.status()
captured = capsys.readouterr()
assert "not running" in captured.out.lower()
class TestSyncDaemonAsync:
"""Tests for async daemon methods."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def daemon_config(self, temp_dir):
"""Create a daemon config with temp paths."""
return create_daemon_config(
pid_file=str(temp_dir / "test_daemon.pid"),
log_file=str(temp_dir / "test_daemon.log"),
)
@pytest.mark.asyncio
async def test_check_for_changes_no_changes(self, daemon_config):
"""Test _check_for_changes when no changes."""
daemon = SyncDaemon(daemon_config)
with patch("src.cli.sync_daemon.should_run_godspeed_sync", return_value=False):
with patch("src.cli.sync_daemon.should_run_sweep", return_value=False):
result = await daemon._check_for_changes()
assert result is False
@pytest.mark.asyncio
async def test_check_for_changes_godspeed_due(self, daemon_config):
"""Test _check_for_changes when godspeed sync is due."""
daemon = SyncDaemon(daemon_config)
with patch("src.cli.sync_daemon.should_run_godspeed_sync", return_value=True):
with patch("src.cli.sync_daemon.should_run_sweep", return_value=False):
result = await daemon._check_for_changes()
assert result is True
@pytest.mark.asyncio
async def test_check_for_changes_sweep_due(self, daemon_config):
"""Test _check_for_changes when sweep is due."""
daemon = SyncDaemon(daemon_config)
with patch("src.cli.sync_daemon.should_run_godspeed_sync", return_value=False):
with patch("src.cli.sync_daemon.should_run_sweep", return_value=True):
result = await daemon._check_for_changes()
assert result is True
@pytest.mark.asyncio
async def test_perform_sync_success(self, daemon_config):
"""Test _perform_sync success."""
daemon = SyncDaemon(daemon_config)
with patch(
"src.cli.sync_daemon._sync_outlook_data", new_callable=AsyncMock
) as mock_sync:
await daemon._perform_sync()
mock_sync.assert_called_once()
@pytest.mark.asyncio
async def test_perform_sync_failure(self, daemon_config):
"""Test _perform_sync handles failure."""
daemon = SyncDaemon(daemon_config)
with patch(
"src.cli.sync_daemon._sync_outlook_data", new_callable=AsyncMock
) as mock_sync:
mock_sync.side_effect = Exception("Sync failed")
# Should not raise
await daemon._perform_sync()
# Error should be logged (we'd need to check the log file)

View File

@@ -0,0 +1,235 @@
"""Tests for the sync dashboard TUI."""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from src.cli.sync_dashboard import (
SyncDashboard,
SyncProgressTracker,
TaskStatus,
TaskListItem,
get_dashboard,
get_progress_tracker,
)
class TestSyncProgressTracker:
"""Tests for the SyncProgressTracker class."""
def test_init(self):
"""Test tracker initialization."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
assert tracker.dashboard == mock_dashboard
def test_start_task(self):
"""Test starting a new task."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.start_task("inbox", 100)
mock_dashboard.start_task.assert_called_once_with("inbox", 100)
def test_start_task_default_total(self):
"""Test starting a task with default total."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.start_task("calendar")
mock_dashboard.start_task.assert_called_once_with("calendar", 100)
def test_update_task(self):
"""Test updating task progress."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.update_task("inbox", 50, "Processing...")
mock_dashboard.update_task.assert_called_once_with("inbox", 50, "Processing...")
def test_update_task_without_message(self):
"""Test updating task without message."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.update_task("inbox", 75)
mock_dashboard.update_task.assert_called_once_with("inbox", 75, "")
def test_complete_task(self):
"""Test completing a task."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.complete_task("inbox", "Done!")
mock_dashboard.complete_task.assert_called_once_with("inbox", "Done!")
def test_complete_task_no_message(self):
"""Test completing a task without a message."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.complete_task("inbox")
mock_dashboard.complete_task.assert_called_once_with("inbox", "")
def test_error_task(self):
"""Test marking a task as failed."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.error_task("inbox", "Connection failed")
mock_dashboard.error_task.assert_called_once_with("inbox", "Connection failed")
def test_skip_task(self):
"""Test skipping a task."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.skip_task("sweep", "Not needed")
mock_dashboard.skip_task.assert_called_once_with("sweep", "Not needed")
def test_skip_task_no_reason(self):
"""Test skipping a task without a reason."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.skip_task("sweep")
mock_dashboard.skip_task.assert_called_once_with("sweep", "")
class TestTaskListItem:
"""Tests for the TaskListItem class."""
def test_init(self):
"""Test TaskListItem initialization."""
item = TaskListItem("inbox", "Inbox Sync")
assert item.task_id == "inbox"
assert item.task_name == "Inbox Sync"
assert item.status == TaskStatus.PENDING
assert item.progress == 0
assert item.total == 100
def test_get_status_icon_pending(self):
"""Test status icon for pending."""
item = TaskListItem("inbox", "Inbox Sync")
assert item._get_status_icon() == ""
def test_get_status_icon_running(self):
"""Test status icon for running (animated spinner)."""
from src.cli.sync_dashboard import SPINNER_FRAMES
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.RUNNING
# Running uses spinner frames, starts at frame 0
assert item._get_status_icon() == SPINNER_FRAMES[0]
def test_get_status_icon_completed(self):
"""Test status icon for completed."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.COMPLETED
assert item._get_status_icon() == ""
def test_get_status_icon_error(self):
"""Test status icon for error."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.ERROR
assert item._get_status_icon() == ""
def test_get_status_color_pending(self):
"""Test status color for pending."""
item = TaskListItem("inbox", "Inbox Sync")
assert item._get_status_color() == "dim"
def test_get_status_color_running(self):
"""Test status color for running."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.RUNNING
assert item._get_status_color() == "cyan"
def test_get_status_color_completed(self):
"""Test status color for completed."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.COMPLETED
assert item._get_status_color() == "bright_white"
def test_get_status_color_error(self):
"""Test status color for error."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.ERROR
assert item._get_status_color() == "red"
class TestTaskStatus:
"""Tests for the TaskStatus constants."""
def test_status_values(self):
"""Test TaskStatus values."""
assert TaskStatus.PENDING == "pending"
assert TaskStatus.RUNNING == "running"
assert TaskStatus.COMPLETED == "completed"
assert TaskStatus.ERROR == "error"
class TestSyncDashboard:
"""Tests for the SyncDashboard class."""
def test_bindings_defined(self):
"""Test that key bindings are defined."""
assert len(SyncDashboard.BINDINGS) > 0
# Bindings use the Binding class which has a key attribute
binding_keys = [b.key for b in SyncDashboard.BINDINGS] # type: ignore
assert "q" in binding_keys
assert "r" in binding_keys
assert "s" in binding_keys # Sync now
assert "+" in binding_keys # Increase interval
assert "-" in binding_keys # Decrease interval
assert "ctrl+c" in binding_keys
assert "up" in binding_keys
assert "down" in binding_keys
def test_css_defined(self):
"""Test that CSS is defined."""
assert SyncDashboard.CSS is not None
assert len(SyncDashboard.CSS) > 0
assert ".dashboard" in SyncDashboard.CSS
assert ".sidebar" in SyncDashboard.CSS
assert ".main-panel" in SyncDashboard.CSS
def test_reactive_selected_task(self):
"""Test selected_task reactive attribute is defined."""
assert hasattr(SyncDashboard, "selected_task")
class TestGlobalInstances:
"""Tests for global dashboard instances."""
def test_get_dashboard_initial(self):
"""Test get_dashboard returns None initially."""
# Reset global state
import src.cli.sync_dashboard as dashboard_module
dashboard_module._dashboard_instance = None
result = get_dashboard()
assert result is None
def test_get_progress_tracker_initial(self):
"""Test get_progress_tracker returns None initially."""
# Reset global state
import src.cli.sync_dashboard as dashboard_module
dashboard_module._progress_tracker = None
result = get_progress_tracker()
assert result is None

654
uv.lock generated
View File

@@ -205,6 +205,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
] ]
[[package]]
name = "black"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
{ url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
{ url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
{ url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" },
{ url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" },
{ url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" },
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
]
[[package]]
name = "build"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "os_name == 'nt'" },
{ name = "packaging" },
{ name = "pyproject-hooks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.4.26" version = "2025.4.26"
@@ -247,6 +293,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
] ]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "2.0.12" version = "2.0.12"
@@ -298,6 +353,80 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
] ]
[[package]]
name = "coverage"
version = "7.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" },
{ url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" },
{ url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" },
{ url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" },
{ url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" },
{ url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" },
{ url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" },
{ url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" },
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "44.0.3" version = "44.0.3"
@@ -342,6 +471,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
] ]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" version = "1.9.0"
@@ -351,6 +489,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
] ]
[[package]]
name = "docutils"
version = "0.22.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" },
]
[[package]] [[package]]
name = "et-xmlfile" name = "et-xmlfile"
version = "2.0.0" version = "2.0.0"
@@ -360,6 +507,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
] ]
[[package]]
name = "filelock"
version = "3.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
]
[[package]] [[package]]
name = "flatbuffers" name = "flatbuffers"
version = "25.2.10" version = "25.2.10"
@@ -436,6 +592,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "certifi" }, { name = "certifi" },
{ name = "click" },
{ name = "html2text" }, { name = "html2text" },
{ name = "mammoth" }, { name = "mammoth" },
{ name = "markitdown", extra = ["all"] }, { name = "markitdown", extra = ["all"] },
@@ -445,6 +602,7 @@ dependencies = [
{ name = "pillow" }, { name = "pillow" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "requests" },
{ name = "rich" }, { name = "rich" },
{ name = "textual" }, { name = "textual" },
{ name = "textual-image" }, { name = "textual-image" },
@@ -453,14 +611,23 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "black" },
{ name = "build" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "textual" }, { name = "textual" },
{ name = "twine" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.11.18" }, { name = "aiohttp", specifier = ">=3.11.18" },
{ name = "certifi", specifier = ">=2025.4.26" }, { name = "certifi", specifier = ">=2025.4.26" },
{ name = "click", specifier = ">=8.1.0" },
{ name = "html2text", specifier = ">=2025.4.15" }, { name = "html2text", specifier = ">=2025.4.15" },
{ name = "mammoth", specifier = ">=1.9.0" }, { name = "mammoth", specifier = ">=1.9.0" },
{ name = "markitdown", extras = ["all"], specifier = ">=0.1.1" }, { name = "markitdown", extras = ["all"], specifier = ">=0.1.1" },
@@ -470,6 +637,7 @@ requires-dist = [
{ name = "pillow", specifier = ">=11.2.1" }, { name = "pillow", specifier = ">=11.2.1" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-docx", specifier = ">=1.1.2" }, { name = "python-docx", specifier = ">=1.1.2" },
{ name = "requests", specifier = ">=2.31.0" },
{ name = "rich", specifier = ">=14.0.0" }, { name = "rich", specifier = ">=14.0.0" },
{ name = "textual", specifier = ">=3.2.0" }, { name = "textual", specifier = ">=3.2.0" },
{ name = "textual-image", specifier = ">=0.8.2" }, { name = "textual-image", specifier = ">=0.8.2" },
@@ -478,8 +646,16 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "black", specifier = ">=24.0.0" },
{ name = "build", specifier = ">=1.0.0" },
{ name = "mypy", specifier = ">=1.8.0" },
{ name = "pre-commit", specifier = ">=3.5.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.11.8" }, { name = "ruff", specifier = ">=0.11.8" },
{ name = "textual", specifier = ">=3.2.0" }, { name = "textual", specifier = ">=3.2.0" },
{ name = "twine", specifier = ">=5.0.0" },
] ]
[[package]] [[package]]
@@ -540,6 +716,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
] ]
[[package]]
name = "id"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" },
]
[[package]]
name = "identify"
version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@@ -549,6 +746,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "isodate" name = "isodate"
version = "0.7.2" version = "0.7.2"
@@ -558,6 +764,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
] ]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]] [[package]]
name = "jiter" name = "jiter"
version = "0.9.0" version = "0.9.0"
@@ -593,6 +841,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867, upload-time = "2025-03-10T21:36:25.843Z" }, { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867, upload-time = "2025-03-10T21:36:25.843Z" },
] ]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "librt"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/d9/6f3d3fcf5e5543ed8a60cc70fa7d50508ed60b8a10e9af6d2058159ab54e/librt-0.7.3.tar.gz", hash = "sha256:3ec50cf65235ff5c02c5b747748d9222e564ad48597122a361269dd3aa808798", size = 144549, upload-time = "2025-12-06T19:04:45.553Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/90/ed8595fa4e35b6020317b5ea8d226a782dcbac7a997c19ae89fb07a41c66/librt-0.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fa9ac2e49a6bee56e47573a6786cb635e128a7b12a0dc7851090037c0d397a3", size = 55687, upload-time = "2025-12-06T19:03:39.245Z" },
{ url = "https://files.pythonhosted.org/packages/dd/f6/6a20702a07b41006cb001a759440cb6b5362530920978f64a2b2ae2bf729/librt-0.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e980cf1ed1a2420a6424e2ed884629cdead291686f1048810a817de07b5eb18", size = 57127, upload-time = "2025-12-06T19:03:40.3Z" },
{ url = "https://files.pythonhosted.org/packages/79/f3/b0c4703d5ffe9359b67bb2ccb86c42d4e930a363cfc72262ac3ba53cff3e/librt-0.7.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e094e445c37c57e9ec612847812c301840239d34ccc5d153a982fa9814478c60", size = 165336, upload-time = "2025-12-06T19:03:41.369Z" },
{ url = "https://files.pythonhosted.org/packages/02/69/3ba05b73ab29ccbe003856232cea4049769be5942d799e628d1470ed1694/librt-0.7.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aca73d70c3f553552ba9133d4a09e767dcfeee352d8d8d3eb3f77e38a3beb3ed", size = 174237, upload-time = "2025-12-06T19:03:42.44Z" },
{ url = "https://files.pythonhosted.org/packages/22/ad/d7c2671e7bf6c285ef408aa435e9cd3fdc06fd994601e1f2b242df12034f/librt-0.7.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c634a0a6db395fdaba0361aa78395597ee72c3aad651b9a307a3a7eaf5efd67e", size = 189017, upload-time = "2025-12-06T19:03:44.01Z" },
{ url = "https://files.pythonhosted.org/packages/f4/94/d13f57193148004592b618555f296b41d2d79b1dc814ff8b3273a0bf1546/librt-0.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a59a69deeb458c858b8fea6acf9e2acd5d755d76cd81a655256bc65c20dfff5b", size = 183983, upload-time = "2025-12-06T19:03:45.834Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/b612a9944ebd39fa143c7e2e2d33f2cb790205e025ddd903fb509a3a3bb3/librt-0.7.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d91e60ac44bbe3a77a67af4a4c13114cbe9f6d540337ce22f2c9eaf7454ca71f", size = 177602, upload-time = "2025-12-06T19:03:46.944Z" },
{ url = "https://files.pythonhosted.org/packages/1f/48/77bc05c4cc232efae6c5592c0095034390992edbd5bae8d6cf1263bb7157/librt-0.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:703456146dc2bf430f7832fd1341adac5c893ec3c1430194fdcefba00012555c", size = 199282, upload-time = "2025-12-06T19:03:48.069Z" },
{ url = "https://files.pythonhosted.org/packages/12/aa/05916ccd864227db1ffec2a303ae34f385c6b22d4e7ce9f07054dbcf083c/librt-0.7.3-cp312-cp312-win32.whl", hash = "sha256:b7c1239b64b70be7759554ad1a86288220bbb04d68518b527783c4ad3fb4f80b", size = 47879, upload-time = "2025-12-06T19:03:49.289Z" },
{ url = "https://files.pythonhosted.org/packages/50/92/7f41c42d31ea818b3c4b9cc1562e9714bac3c676dd18f6d5dd3d0f2aa179/librt-0.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef59c938f72bdbc6ab52dc50f81d0637fde0f194b02d636987cea2ab30f8f55a", size = 54972, upload-time = "2025-12-06T19:03:50.335Z" },
{ url = "https://files.pythonhosted.org/packages/3f/dc/53582bbfb422311afcbc92adb75711f04e989cec052f08ec0152fbc36c9c/librt-0.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:ff21c554304e8226bf80c3a7754be27c6c3549a9fec563a03c06ee8f494da8fc", size = 48338, upload-time = "2025-12-06T19:03:51.431Z" },
{ url = "https://files.pythonhosted.org/packages/93/7d/e0ce1837dfb452427db556e6d4c5301ba3b22fe8de318379fbd0593759b9/librt-0.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56f2a47beda8409061bc1c865bef2d4bd9ff9255219402c0817e68ab5ad89aed", size = 55742, upload-time = "2025-12-06T19:03:52.459Z" },
{ url = "https://files.pythonhosted.org/packages/be/c0/3564262301e507e1d5cf31c7d84cb12addf0d35e05ba53312494a2eba9a4/librt-0.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14569ac5dd38cfccf0a14597a88038fb16811a6fede25c67b79c6d50fc2c8fdc", size = 57163, upload-time = "2025-12-06T19:03:53.516Z" },
{ url = "https://files.pythonhosted.org/packages/be/ac/245e72b7e443d24a562f6047563c7f59833384053073ef9410476f68505b/librt-0.7.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6038ccbd5968325a5d6fd393cf6e00b622a8de545f0994b89dd0f748dcf3e19e", size = 165840, upload-time = "2025-12-06T19:03:54.918Z" },
{ url = "https://files.pythonhosted.org/packages/98/af/587e4491f40adba066ba39a450c66bad794c8d92094f936a201bfc7c2b5f/librt-0.7.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d39079379a9a28e74f4d57dc6357fa310a1977b51ff12239d7271ec7e71d67f5", size = 174827, upload-time = "2025-12-06T19:03:56.082Z" },
{ url = "https://files.pythonhosted.org/packages/78/21/5b8c60ea208bc83dd00421022a3874330685d7e856404128dc3728d5d1af/librt-0.7.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8837d5a52a2d7aa9f4c3220a8484013aed1d8ad75240d9a75ede63709ef89055", size = 189612, upload-time = "2025-12-06T19:03:57.507Z" },
{ url = "https://files.pythonhosted.org/packages/da/2f/8b819169ef696421fb81cd04c6cdf225f6e96f197366001e9d45180d7e9e/librt-0.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:399bbd7bcc1633c3e356ae274a1deb8781c7bf84d9c7962cc1ae0c6e87837292", size = 184584, upload-time = "2025-12-06T19:03:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fc/af9d225a9395b77bd7678362cb055d0b8139c2018c37665de110ca388022/librt-0.7.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d8cf653e798ee4c4e654062b633db36984a1572f68c3aa25e364a0ddfbbb910", size = 178269, upload-time = "2025-12-06T19:03:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d8/7b4fa1683b772966749d5683aa3fd605813defffe157833a8fa69cc89207/librt-0.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f03484b54bf4ae80ab2e504a8d99d20d551bfe64a7ec91e218010b467d77093", size = 199852, upload-time = "2025-12-06T19:04:00.901Z" },
{ url = "https://files.pythonhosted.org/packages/77/e8/4598413aece46ca38d9260ef6c51534bd5f34b5c21474fcf210ce3a02123/librt-0.7.3-cp313-cp313-win32.whl", hash = "sha256:44b3689b040df57f492e02cd4f0bacd1b42c5400e4b8048160c9d5e866de8abe", size = 47936, upload-time = "2025-12-06T19:04:02.054Z" },
{ url = "https://files.pythonhosted.org/packages/af/80/ac0e92d5ef8c6791b3e2c62373863827a279265e0935acdf807901353b0e/librt-0.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:6b407c23f16ccc36614c136251d6b32bf30de7a57f8e782378f1107be008ddb0", size = 54965, upload-time = "2025-12-06T19:04:03.224Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fd/042f823fcbff25c1449bb4203a29919891ca74141b68d3a5f6612c4ce283/librt-0.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:abfc57cab3c53c4546aee31859ef06753bfc136c9d208129bad23e2eca39155a", size = 48350, upload-time = "2025-12-06T19:04:04.234Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ae/c6ecc7bb97134a71b5241e8855d39964c0e5f4d96558f0d60593892806d2/librt-0.7.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:120dd21d46ff875e849f1aae19346223cf15656be489242fe884036b23d39e93", size = 55175, upload-time = "2025-12-06T19:04:05.308Z" },
{ url = "https://files.pythonhosted.org/packages/cf/bc/2cc0cb0ab787b39aa5c7645cd792433c875982bdf12dccca558b89624594/librt-0.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1617bea5ab31266e152871208502ee943cb349c224846928a1173c864261375e", size = 56881, upload-time = "2025-12-06T19:04:06.674Z" },
{ url = "https://files.pythonhosted.org/packages/8e/87/397417a386190b70f5bf26fcedbaa1515f19dce33366e2684c6b7ee83086/librt-0.7.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93b2a1f325fefa1482516ced160c8c7b4b8d53226763fa6c93d151fa25164207", size = 163710, upload-time = "2025-12-06T19:04:08.437Z" },
{ url = "https://files.pythonhosted.org/packages/c9/37/7338f85b80e8a17525d941211451199845093ca242b32efbf01df8531e72/librt-0.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d4801db8354436fd3936531e7f0e4feb411f62433a6b6cb32bb416e20b529f", size = 172471, upload-time = "2025-12-06T19:04:10.124Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e0/741704edabbfae2c852fedc1b40d9ed5a783c70ed3ed8e4fe98f84b25d13/librt-0.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11ad45122bbed42cfc8b0597450660126ef28fd2d9ae1a219bc5af8406f95678", size = 186804, upload-time = "2025-12-06T19:04:11.586Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d1/0a82129d6ba242f3be9af34815be089f35051bc79619f5c27d2c449ecef6/librt-0.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6b4e7bff1d76dd2b46443078519dc75df1b5e01562345f0bb740cea5266d8218", size = 181817, upload-time = "2025-12-06T19:04:12.802Z" },
{ url = "https://files.pythonhosted.org/packages/4f/32/704f80bcf9979c68d4357c46f2af788fbf9d5edda9e7de5786ed2255e911/librt-0.7.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d86f94743a11873317094326456b23f8a5788bad9161fd2f0e52088c33564620", size = 175602, upload-time = "2025-12-06T19:04:14.004Z" },
{ url = "https://files.pythonhosted.org/packages/f7/6d/4355cfa0fae0c062ba72f541d13db5bc575770125a7ad3d4f46f4109d305/librt-0.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:754a0d09997095ad764ccef050dd5bf26cbf457aab9effcba5890dad081d879e", size = 196497, upload-time = "2025-12-06T19:04:15.487Z" },
{ url = "https://files.pythonhosted.org/packages/2e/eb/ac6d8517d44209e5a712fde46f26d0055e3e8969f24d715f70bd36056230/librt-0.7.3-cp314-cp314-win32.whl", hash = "sha256:fbd7351d43b80d9c64c3cfcb50008f786cc82cba0450e8599fdd64f264320bd3", size = 44678, upload-time = "2025-12-06T19:04:16.688Z" },
{ url = "https://files.pythonhosted.org/packages/e9/93/238f026d141faf9958da588c761a0812a1a21c98cc54a76f3608454e4e59/librt-0.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:d376a35c6561e81d2590506804b428fc1075fcc6298fc5bb49b771534c0ba010", size = 51689, upload-time = "2025-12-06T19:04:17.726Z" },
{ url = "https://files.pythonhosted.org/packages/52/44/43f462ad9dcf9ed7d3172fe2e30d77b980956250bd90e9889a9cca93df2a/librt-0.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:cbdb3f337c88b43c3b49ca377731912c101178be91cb5071aac48faa898e6f8e", size = 44662, upload-time = "2025-12-06T19:04:18.771Z" },
{ url = "https://files.pythonhosted.org/packages/1d/35/fed6348915f96b7323241de97f26e2af481e95183b34991df12fd5ce31b1/librt-0.7.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9f0e0927efe87cd42ad600628e595a1a0aa1c64f6d0b55f7e6059079a428641a", size = 57347, upload-time = "2025-12-06T19:04:19.812Z" },
{ url = "https://files.pythonhosted.org/packages/9a/f2/045383ccc83e3fea4fba1b761796584bc26817b6b2efb6b8a6731431d16f/librt-0.7.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:020c6db391268bcc8ce75105cb572df8cb659a43fd347366aaa407c366e5117a", size = 59223, upload-time = "2025-12-06T19:04:20.862Z" },
{ url = "https://files.pythonhosted.org/packages/77/3f/c081f8455ab1d7f4a10dbe58463ff97119272ff32494f21839c3b9029c2c/librt-0.7.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7af7785f5edd1f418da09a8cdb9ec84b0213e23d597413e06525340bcce1ea4f", size = 183861, upload-time = "2025-12-06T19:04:21.963Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f5/73c5093c22c31fbeaebc25168837f05ebfd8bf26ce00855ef97a5308f36f/librt-0.7.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ccadf260bb46a61b9c7e89e2218f6efea9f3eeaaab4e3d1f58571890e54858e", size = 194594, upload-time = "2025-12-06T19:04:23.14Z" },
{ url = "https://files.pythonhosted.org/packages/78/b8/d5f17d4afe16612a4a94abfded94c16c5a033f183074fb130dfe56fc1a42/librt-0.7.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9883b2d819ce83f87ba82a746c81d14ada78784db431e57cc9719179847376e", size = 206759, upload-time = "2025-12-06T19:04:24.328Z" },
{ url = "https://files.pythonhosted.org/packages/36/2e/021765c1be85ee23ffd5b5b968bb4cba7526a4db2a0fc27dcafbdfc32da7/librt-0.7.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:59cb0470612d21fa1efddfa0dd710756b50d9c7fb6c1236bbf8ef8529331dc70", size = 203210, upload-time = "2025-12-06T19:04:25.544Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/9923656e42da4fd18c594bd08cf6d7e152d4158f8b808e210d967f0dcceb/librt-0.7.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1fe603877e1865b5fd047a5e40379509a4a60204aa7aa0f72b16f7a41c3f0712", size = 196708, upload-time = "2025-12-06T19:04:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/fc/0b/0708b886ac760e64d6fbe7e16024e4be3ad1a3629d19489a97e9cf4c3431/librt-0.7.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5460d99ed30f043595bbdc888f542bad2caeb6226b01c33cda3ae444e8f82d42", size = 217212, upload-time = "2025-12-06T19:04:27.892Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7f/12a73ff17bca4351e73d585dd9ebf46723c4a8622c4af7fe11a2e2d011ff/librt-0.7.3-cp314-cp314t-win32.whl", hash = "sha256:d09f677693328503c9e492e33e9601464297c01f9ebd966ea8fc5308f3069bfd", size = 45586, upload-time = "2025-12-06T19:04:29.116Z" },
{ url = "https://files.pythonhosted.org/packages/e2/df/8decd032ac9b995e4f5606cde783711a71094128d88d97a52e397daf2c89/librt-0.7.3-cp314-cp314t-win_amd64.whl", hash = "sha256:25711f364c64cab2c910a0247e90b51421e45dbc8910ceeb4eac97a9e132fc6f", size = 53002, upload-time = "2025-12-06T19:04:30.173Z" },
{ url = "https://files.pythonhosted.org/packages/de/0c/6605b6199de8178afe7efc77ca1d8e6db00453bc1d3349d27605c0f42104/librt-0.7.3-cp314-cp314t-win_arm64.whl", hash = "sha256:a9f9b661f82693eb56beb0605156c7fca57f535704ab91837405913417d6990b", size = 45647, upload-time = "2025-12-06T19:04:31.302Z" },
]
[[package]] [[package]]
name = "linkify-it-py" name = "linkify-it-py"
version = "2.0.3" version = "2.0.3"
@@ -763,6 +1080,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
] ]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]] [[package]]
name = "mpmath" name = "mpmath"
version = "1.3.0" version = "1.3.0"
@@ -858,6 +1184,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" }, { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" },
] ]
[[package]]
name = "mypy"
version = "1.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728, upload-time = "2025-11-28T15:46:26.463Z" },
{ url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945, upload-time = "2025-11-28T15:48:49.143Z" },
{ url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673, upload-time = "2025-11-28T15:47:37.193Z" },
{ url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336, upload-time = "2025-11-28T15:48:32.625Z" },
{ url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174, upload-time = "2025-11-28T15:45:48.091Z" },
{ url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208, upload-time = "2025-11-28T15:46:41.702Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993, upload-time = "2025-11-28T15:47:22.336Z" },
{ url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411, upload-time = "2025-11-28T15:44:55.492Z" },
{ url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751, upload-time = "2025-11-28T15:44:14.169Z" },
{ url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323, upload-time = "2025-11-28T15:46:34.699Z" },
{ url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032, upload-time = "2025-11-28T15:46:18.286Z" },
{ url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644, upload-time = "2025-11-28T15:47:43.99Z" },
{ url = "https://files.pythonhosted.org/packages/b0/f4/7751f32f56916f7f8c229fe902cbdba3e4dd3f3ea9e8b872be97e7fc546d/mypy-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f2e36bed3c6d9b5f35d28b63ca4b727cb0228e480826ffc8953d1892ddc8999d", size = 13185236, upload-time = "2025-11-28T15:45:20.696Z" },
{ url = "https://files.pythonhosted.org/packages/35/31/871a9531f09e78e8d145032355890384f8a5b38c95a2c7732d226b93242e/mypy-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a18d8abdda14035c5718acb748faec09571432811af129bf0d9e7b2d6699bf18", size = 12213902, upload-time = "2025-11-28T15:46:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/58/b8/af221910dd40eeefa2077a59107e611550167b9994693fc5926a0b0f87c0/mypy-1.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75e60aca3723a23511948539b0d7ed514dda194bc3755eae0bfc7a6b4887aa7", size = 12738600, upload-time = "2025-11-28T15:44:22.521Z" },
{ url = "https://files.pythonhosted.org/packages/11/9f/c39e89a3e319c1d9c734dedec1183b2cc3aefbab066ec611619002abb932/mypy-1.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f44f2ae3c58421ee05fe609160343c25f70e3967f6e32792b5a78006a9d850f", size = 13592639, upload-time = "2025-11-28T15:48:08.55Z" },
{ url = "https://files.pythonhosted.org/packages/97/6d/ffaf5f01f5e284d9033de1267e6c1b8f3783f2cf784465378a86122e884b/mypy-1.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63ea6a00e4bd6822adbfc75b02ab3653a17c02c4347f5bb0cf1d5b9df3a05835", size = 13799132, upload-time = "2025-11-28T15:47:06.032Z" },
{ url = "https://files.pythonhosted.org/packages/fe/b0/c33921e73aaa0106224e5a34822411bea38046188eb781637f5a5b07e269/mypy-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ad925b14a0bb99821ff6f734553294aa6a3440a8cb082fe1f5b84dfb662afb1", size = 10269832, upload-time = "2025-11-28T15:47:29.392Z" },
{ url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nh3"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839, upload-time = "2025-10-30T11:17:09.956Z" },
{ url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183, upload-time = "2025-10-30T11:17:11.99Z" },
{ url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127, upload-time = "2025-10-30T11:17:13.192Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131, upload-time = "2025-10-30T11:17:14.677Z" },
{ url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783, upload-time = "2025-10-30T11:17:15.861Z" },
{ url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732, upload-time = "2025-10-30T11:17:17.155Z" },
{ url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997, upload-time = "2025-10-30T11:17:18.77Z" },
{ url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364, upload-time = "2025-10-30T11:17:20.286Z" },
{ url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982, upload-time = "2025-10-30T11:17:21.384Z" },
{ url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126, upload-time = "2025-10-30T11:17:22.755Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" },
{ url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" },
{ url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" },
{ url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" },
{ url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" },
{ url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" },
{ url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
{ url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
{ url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.2.5" version = "2.2.5"
@@ -1042,6 +1452,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" }, { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" },
] ]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]] [[package]]
name = "pdfminer-six" name = "pdfminer-six"
version = "20250506" version = "20250506"
@@ -1105,6 +1524,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
]
[[package]] [[package]]
name = "propcache" name = "propcache"
version = "0.3.1" version = "0.3.1"
@@ -1274,6 +1718,15 @@ crypto = [
{ name = "cryptography" }, { name = "cryptography" },
] ]
[[package]]
name = "pyproject-hooks"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
]
[[package]] [[package]]
name = "pyreadline3" name = "pyreadline3"
version = "3.5.4" version = "3.5.4"
@@ -1283,6 +1736,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -1332,6 +1828,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
] ]
[[package]]
name = "pytokens"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
]
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2021.1" version = "2021.1"
@@ -1341,6 +1846,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/94/784178ca5dd892a98f113cdd923372024dc04b8d40abe77ca76b5fb90ca6/pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798", size = 510782, upload-time = "2021-02-01T08:07:15.659Z" }, { url = "https://files.pythonhosted.org/packages/70/94/784178ca5dd892a98f113cdd923372024dc04b8d40abe77ca76b5fb90ca6/pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798", size = 510782, upload-time = "2021-02-01T08:07:15.659Z" },
] ]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "readme-renderer"
version = "44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "nh3" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" },
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "2021.4.4" version = "2021.4.4"
@@ -1349,7 +1923,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/38/3f/4c42a98c9ad7d08c1
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.26.0" version = "2.32.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -1357,9 +1931,30 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e7/01/3569e0b535fb2e4a6c384bdbed00c55b9d78b5084e0fb7f4d0bf523d7670/requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7", size = 104433, upload-time = "2021-07-13T14:55:08.972Z" } 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" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/96/144f70b972a9c0eabbd4391ef93ccd49d0f2747f4f6a2a2738e99e5adc65/requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", size = 62251, upload-time = "2021-07-13T14:55:06.933Z" }, { 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" },
]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "rfc3986"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" },
] ]
[[package]] [[package]]
@@ -1400,6 +1995,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/be/f6b790d6ae98f1f32c645f8540d5c96248b72343b0a56fab3a07f2941897/ruff-0.11.8-py3-none-win_arm64.whl", hash = "sha256:304432e4c4a792e3da85b7699feb3426a0908ab98bf29df22a31b0cdd098fac2", size = 10713129, upload-time = "2025-05-01T14:53:22.27Z" }, { url = "https://files.pythonhosted.org/packages/cd/be/f6b790d6ae98f1f32c645f8540d5c96248b72343b0a56fab3a07f2941897/ruff-0.11.8-py3-none-win_arm64.whl", hash = "sha256:304432e4c4a792e3da85b7699feb3426a0908ab98bf29df22a31b0cdd098fac2", size = 10713129, upload-time = "2025-05-01T14:53:22.27Z" },
] ]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@@ -1505,7 +2113,7 @@ wheels = [
[[package]] [[package]]
name = "ticktick-py" name = "ticktick-py"
version = "2.0.3" version = "2.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pytz" }, { name = "pytz" },
@@ -1513,9 +2121,9 @@ dependencies = [
{ name = "requests" }, { name = "requests" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/91/7b/f1bd14f6aa2cb021c82f6f71f81f1b4057410879b71c5d261897476bc539/ticktick-py-2.0.3.tar.gz", hash = "sha256:f6e96870b91f16717a81e20ffef4a2f5b2a524d6a79e31ab64e895a90a372b51", size = 44939, upload-time = "2023-07-09T05:05:19.759Z" } sdist = { url = "https://files.pythonhosted.org/packages/65/15/562e6ea29d39ce1cf7943c7819bab4e4a0811bc0bab2c03a8fc2731b2038/ticktick-py-2.0.1.tar.gz", hash = "sha256:4433ac15d1e2540827f225a6db4669b6242db6d3d882636aa0acdf2792985101", size = 45018, upload-time = "2021-06-24T20:24:08.659Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/6b/1af30827789520a724906e7249531007ddc4fab5a4b50f4e5275dc73324f/ticktick_py-2.0.3-py3-none-any.whl", hash = "sha256:5fdb01fa45b1b477ed2921a48db106f090b9519cf52246647e67465414479840", size = 46079, upload-time = "2023-07-09T05:05:18.153Z" }, { url = "https://files.pythonhosted.org/packages/de/cb/6291e38d14a52c73a4bf62a5cde88855741c1294f4a68cf38b46861d8480/ticktick_py-2.0.1-py3-none-any.whl", hash = "sha256:676c603322010ba9e508eda71698e917a3e2ba472bcfd26be2e5db198455fda5", size = 45675, upload-time = "2021-06-24T20:24:07.355Z" },
] ]
[[package]] [[package]]
@@ -1530,6 +2138,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
] ]
[[package]]
name = "twine"
version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "id" },
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "packaging" },
{ name = "readme-renderer" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "rfc3986" },
{ name = "rich" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.13.2" version = "4.13.2"
@@ -1578,6 +2206,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/f4/524415c0744552cce7d8bf3669af78e8a069514405ea4fcbd0cc44733744/urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844", size = 138764, upload-time = "2021-09-22T18:01:15.93Z" }, { url = "https://files.pythonhosted.org/packages/af/f4/524415c0744552cce7d8bf3669af78e8a069514405ea4fcbd0cc44733744/urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844", size = 138764, upload-time = "2021-09-22T18:01:15.93Z" },
] ]
[[package]]
name = "virtualenv"
version = "20.35.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
[[package]] [[package]]
name = "xlrd" name = "xlrd"
version = "2.0.1" version = "2.0.1"