dashboard sync app
This commit is contained in:
284
README.md
284
README.md
@@ -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
|
||||
|
||||
- Download Emails to a local maildir
|
||||
- Download Events to a local VDIR
|
||||
- two-way sync of locally changed files
|
||||
- View OneDrive Folders and Files in your terminal
|
||||
- a couple different ways to view email messages locally, but you should probably be using [aerc]
|
||||
- **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
|
||||
|
||||
88
check_env.py
Executable file
88
check_env.py
Executable 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
187
install.sh
Executable 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
323
luk.egg-info/PKG-INFO
Normal 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
76
luk.egg-info/SOURCES.txt
Normal 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
|
||||
1
luk.egg-info/dependency_links.txt
Normal file
1
luk.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2
luk.egg-info/entry_points.txt
Normal file
2
luk.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
luk = src.cli.__main__:main
|
||||
17
luk.egg-info/requires.txt
Normal file
17
luk.egg-info/requires.txt
Normal 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
|
||||
1
luk.egg-info/top_level.txt
Normal file
1
luk.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
src
|
||||
103
pyproject.toml
103
pyproject.toml
@@ -1,9 +1,27 @@
|
||||
[project]
|
||||
name = "gtd-terminal-tools"
|
||||
name = "luk"
|
||||
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"
|
||||
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 = [
|
||||
"aiohttp>=3.11.18",
|
||||
"certifi>=2025.4.26",
|
||||
@@ -24,8 +42,89 @@ dependencies = [
|
||||
"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]
|
||||
dev = [
|
||||
"ruff>=0.11.8",
|
||||
"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"]
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from . import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -31,7 +31,7 @@ def start(config, daemon):
|
||||
cmd.extend(["--config", config])
|
||||
|
||||
# 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)
|
||||
|
||||
# Start daemon process
|
||||
@@ -61,7 +61,7 @@ def start(config, daemon):
|
||||
@gitlab_monitor.command()
|
||||
def stop():
|
||||
"""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):
|
||||
click.echo("Daemon is not running (no PID file found)")
|
||||
@@ -88,7 +88,7 @@ def stop():
|
||||
@gitlab_monitor.command()
|
||||
def status():
|
||||
"""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):
|
||||
click.echo("Daemon is not running")
|
||||
|
||||
156
src/cli/sync.py
156
src/cli/sync.py
@@ -1,6 +1,8 @@
|
||||
import click
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -31,7 +33,7 @@ from src.services.godspeed.sync import GodspeedSync
|
||||
# Timing state management
|
||||
def get_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():
|
||||
@@ -97,7 +99,7 @@ def get_godspeed_sync_directory():
|
||||
return docs_dir
|
||||
|
||||
# Fall back to data directory
|
||||
data_dir = home / ".local" / "share" / "gtd-terminal-tools" / "godspeed"
|
||||
data_dir = home / ".local" / "share" / "luk" / "godspeed"
|
||||
return data_dir
|
||||
|
||||
|
||||
@@ -265,11 +267,12 @@ async def fetch_calendar_async(
|
||||
# Update progress bar with 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
|
||||
if not dry_run:
|
||||
if vdir_path:
|
||||
# Create org-specific directory within vdir path
|
||||
org_vdir_path = os.path.join(vdir_path, org_name)
|
||||
if vdir_path and org_vdir_path:
|
||||
progress.console.print(
|
||||
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)
|
||||
|
||||
if not dry_run:
|
||||
if vdir_path:
|
||||
if vdir_path and org_vdir_path:
|
||||
save_events_to_vdir(
|
||||
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}"
|
||||
)
|
||||
messages_before = 0
|
||||
if notify:
|
||||
new_dir = os.path.join(maildir_path, "new")
|
||||
cur_dir = os.path.join(maildir_path, "cur")
|
||||
if notify:
|
||||
if os.path.exists(new_dir):
|
||||
messages_before += len([f for f in os.listdir(new_dir) if ".eml" in f])
|
||||
if os.path.exists(cur_dir):
|
||||
@@ -572,7 +575,51 @@ async def _sync_outlook_data(
|
||||
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(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
@@ -628,13 +675,19 @@ async def _sync_outlook_data(
|
||||
help="Run in daemon mode.",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--dashboard",
|
||||
is_flag=True,
|
||||
help="Run with TUI dashboard.",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--notify",
|
||||
is_flag=True,
|
||||
help="Send macOS notifications for new email messages",
|
||||
default=False,
|
||||
)
|
||||
def sync(
|
||||
def run(
|
||||
dry_run,
|
||||
vdir,
|
||||
icsfile,
|
||||
@@ -645,23 +698,31 @@ def sync(
|
||||
download_attachments,
|
||||
two_way_calendar,
|
||||
daemon,
|
||||
dashboard,
|
||||
notify,
|
||||
):
|
||||
if daemon:
|
||||
asyncio.run(
|
||||
daemon_mode(
|
||||
dry_run,
|
||||
vdir,
|
||||
icsfile,
|
||||
org,
|
||||
days_back,
|
||||
days_forward,
|
||||
continue_iteration,
|
||||
download_attachments,
|
||||
two_way_calendar,
|
||||
notify,
|
||||
)
|
||||
if dashboard:
|
||||
from .sync_dashboard import run_dashboard_sync
|
||||
|
||||
asyncio.run(run_dashboard_sync())
|
||||
elif daemon:
|
||||
from .sync_daemon import create_daemon_config, SyncDaemon
|
||||
|
||||
config = create_daemon_config(
|
||||
dry_run=dry_run,
|
||||
vdir=vdir,
|
||||
icsfile=icsfile,
|
||||
org=org,
|
||||
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:
|
||||
asyncio.run(
|
||||
_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):
|
||||
"""
|
||||
Check if there are local calendar changes that need syncing.
|
||||
|
||||
329
src/cli/sync_daemon.py
Normal file
329
src/cli/sync_daemon.py
Normal 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
680
src/cli/sync_dashboard.py
Normal 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())
|
||||
@@ -9,7 +9,7 @@ class GitLabMonitorConfig:
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
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()
|
||||
|
||||
@@ -56,9 +56,7 @@ class GitLabMonitorConfig:
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"log_file": os.path.expanduser(
|
||||
"~/.config/gtd-tools/gitlab_monitor.log"
|
||||
),
|
||||
"log_file": os.path.expanduser("~/.local/share/luk/gitlab_monitor.log"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ class GodspeedClient:
|
||||
|
||||
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.password = password
|
||||
self.token = token
|
||||
@@ -60,7 +65,9 @@ class GodspeedClient:
|
||||
response.raise_for_status()
|
||||
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."""
|
||||
params = {}
|
||||
if list_id:
|
||||
@@ -81,8 +88,8 @@ class GodspeedClient:
|
||||
def create_task(
|
||||
self,
|
||||
title: str,
|
||||
list_id: str = None,
|
||||
notes: str = None,
|
||||
list_id: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
location: str = "end",
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
@@ -63,9 +63,22 @@ def get_access_token(scopes):
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
token_response = None
|
||||
|
||||
# Try silent authentication first
|
||||
if accounts:
|
||||
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)
|
||||
if "user_code" not in flow:
|
||||
raise Exception("Failed to create device flow")
|
||||
|
||||
@@ -18,16 +18,50 @@ semaphore = asyncio.Semaphore(2)
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
return await func(*args)
|
||||
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
|
||||
if "429" in error_str:
|
||||
print(
|
||||
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)
|
||||
continue
|
||||
raise e
|
||||
@@ -55,10 +89,11 @@ async def _fetch_impl(url, headers):
|
||||
async with semaphore:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status == 429:
|
||||
# Let the retry handler deal with throttling
|
||||
if response.status in [401, 429]:
|
||||
# Let the retry handler deal with authentication and throttling
|
||||
response_text = await response.text()
|
||||
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:
|
||||
raise Exception(
|
||||
@@ -92,9 +127,10 @@ async def _post_impl(url, headers, json_data):
|
||||
async with semaphore:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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(
|
||||
f"Failed to post {url}: {response.status} {await response.text()}"
|
||||
f"Failed to post {url}: {response.status} {response_text}"
|
||||
)
|
||||
return response.status
|
||||
|
||||
@@ -119,9 +155,10 @@ async def _patch_impl(url, headers, json_data):
|
||||
async with semaphore:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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(
|
||||
f"Failed to patch {url}: {response.status} {await response.text()}"
|
||||
f"Failed to patch {url}: {response.status} {response_text}"
|
||||
)
|
||||
return response.status
|
||||
|
||||
@@ -145,9 +182,10 @@ async def _delete_impl(url, headers):
|
||||
async with semaphore:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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(
|
||||
f"Failed to delete {url}: {response.status} {await response.text()}"
|
||||
f"Failed to delete {url}: {response.status} {response_text}"
|
||||
)
|
||||
return response.status
|
||||
|
||||
@@ -176,9 +214,10 @@ async def _batch_impl(requests, headers):
|
||||
async with session.post(
|
||||
batch_url, headers=headers, json=batch_data
|
||||
) as response:
|
||||
if response.status == 429:
|
||||
if response.status in [401, 429]:
|
||||
response_text = await response.text()
|
||||
raise Exception(
|
||||
f"Batch request failed: {response.status} {await response.text()}"
|
||||
f"Batch request failed: {response.status} {response_text}"
|
||||
)
|
||||
elif response.status != 200:
|
||||
raise Exception(
|
||||
|
||||
352
src/utils/platform.py
Normal file
352
src/utils/platform.py
Normal 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
400
tests/test_platform.py
Normal 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
239
tests/test_sync_daemon.py
Normal 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)
|
||||
235
tests/test_sync_dashboard.py
Normal file
235
tests/test_sync_dashboard.py
Normal 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
654
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "charset-normalizer"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cryptography"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "distro"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "et-xmlfile"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "flatbuffers"
|
||||
version = "25.2.10"
|
||||
@@ -436,6 +592,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "certifi" },
|
||||
{ name = "click" },
|
||||
{ name = "html2text" },
|
||||
{ name = "mammoth" },
|
||||
{ name = "markitdown", extra = ["all"] },
|
||||
@@ -445,6 +602,7 @@ dependencies = [
|
||||
{ name = "pillow" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "textual" },
|
||||
{ name = "textual-image" },
|
||||
@@ -453,14 +611,23 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "build" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "textual" },
|
||||
{ name = "twine" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.11.18" },
|
||||
{ name = "certifi", specifier = ">=2025.4.26" },
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "html2text", specifier = ">=2025.4.15" },
|
||||
{ name = "mammoth", specifier = ">=1.9.0" },
|
||||
{ name = "markitdown", extras = ["all"], specifier = ">=0.1.1" },
|
||||
@@ -470,6 +637,7 @@ requires-dist = [
|
||||
{ name = "pillow", specifier = ">=11.2.1" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "python-docx", specifier = ">=1.1.2" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "rich", specifier = ">=14.0.0" },
|
||||
{ name = "textual", specifier = ">=3.2.0" },
|
||||
{ name = "textual-image", specifier = ">=0.8.2" },
|
||||
@@ -478,8 +646,16 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-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 = "textual", specifier = ">=3.2.0" },
|
||||
{ name = "twine", specifier = ">=5.0.0" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "idna"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "isodate"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "jiter"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "linkify-it-py"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "mpmath"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "numpy"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pdfminer-six"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "propcache"
|
||||
version = "0.3.1"
|
||||
@@ -1274,6 +1718,15 @@ crypto = [
|
||||
{ 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]]
|
||||
name = "pyreadline3"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dateutil"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pytz"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "regex"
|
||||
version = "2021.4.4"
|
||||
@@ -1349,7 +1923,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/38/3f/4c42a98c9ad7d08c1
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.26.0"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1357,9 +1931,30 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -1505,7 +2113,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ticktick-py"
|
||||
version = "2.0.3"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytz" },
|
||||
@@ -1513,9 +2121,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "typing-extensions"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "xlrd"
|
||||
version = "2.0.1"
|
||||
|
||||
Reference in New Issue
Block a user