diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index e5497cf78..23f4fe03e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -33,16 +33,19 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install flake8 pylint mypy + pip install flake8 pylint mypy pytest - name: Validate Python syntax run: python -m compileall . -q - name: Test CLI help - run: python create_map_poster.py --help + run: python -m opencartograph --help - name: Test list themes - run: python create_map_poster.py --list-themes + run: python -m opencartograph --list-themes + + - name: Run unit tests + run: pytest tests/ -v --tb=short - name: Lint with flake8 run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e73a26b2..4b843553f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - Community Contributions +## [Unreleased] + +## [0.4.0] - OpenCartograph ### Added +- **Package rename** - Renamed from `maptoposter` to `opencartograph` + - New `opencartograph` Python package with proper module structure + - CLI entry point: `opencartograph` command via `pyproject.toml` scripts + - Removed legacy `create_map_poster.py` and `font_management.py` entry points +- **`--no-text` flag** - Generate posters without typography overlay +- **Expanded green-space rendering** - Parks layer now includes cemeteries (`landuse=cemetery`) and woods (`natural=wood`) +- **OSMnx caching** - Enabled `ox.settings.use_cache = True` for faster repeated fetches - **uv package manager support** ([PR #20](https://github.com/originalankur/maptoposter/pull/20)) - Added `pyproject.toml` with project metadata and dependencies - Added `uv.lock` for reproducible builds - - Added shebang to `create_map_poster.py` for direct execution - Updated README with uv installation instructions - **Python version specification** - `requires-python = ">=3.11"` in pyproject.toml (fixes [#79](https://github.com/originalankur/maptoposter/issues/79)) - **Coordinate override** - `--latitude` and `--longitude` arguments to override the geocoded center point (existing from upstream PR #106, clarifies [#100](https://github.com/originalankur/maptoposter/issues/100)) diff --git a/README.md b/README.md index e5dca16cd..e3bfd14ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCartograph -Generate beautiful, minimalist cartographs for any city in the world. +An open-source cartographic rendering tool. Generate high-quality map visualizations for any location worldwide using OpenStreetMap data. Supports multiple visual themes, export formats, and fully customizable output. @@ -24,173 +24,107 @@ Generate beautiful, minimalist cartographs for any city in the world. ### With uv (Recommended) -Make sure [uv](https://docs.astral.sh/uv/) is installed. Running the script by prepending `uv run` automatically creates and manages a virtual environment. +Make sure [uv](https://docs.astral.sh/uv/) is installed. ```bash -# First run will automatically install dependencies -uv run ./create_map_poster.py --city "Paris" --country "France" - -# Or sync dependencies explicitly first (using locked versions) -uv sync --locked -uv run ./create_map_poster.py --city "Paris" --country "France" +git clone https://github.com/rramboer/OpenCartograph.git +cd OpenCartograph +uv sync ``` ### With pip + venv ```bash +git clone https://github.com/rramboer/OpenCartograph.git +cd OpenCartograph python -m venv .venv source .venv/bin/activate # On Windows: .venv\Scripts\activate -pip install -r requirements.txt +pip install . ``` -## Usage - -### Generate Poster - -If you're using `uv`: +## Quick Start ```bash -uv run ./create_map_poster.py --city --country [options] -``` +# Generate a poster +opencartograph --city "Paris" --country "France" -Otherwise (pip + venv): +# Or run as a module +python -m opencartograph --city "Paris" --country "France" -```bash -python create_map_poster.py --city --country [options] +# With uv (if not installed as a CLI) +uv run python -m opencartograph --city "Paris" --country "France" ``` +## Usage + ### Required Options | Option | Short | Description | |--------|-------|-------------| -| `--city` | `-c` | City name (used for geocoding) | -| `--country` | `-C` | Country name (used for geocoding) | +| `--city` | `-c` | City name | +| `--country` | `-C` | Country name | -### Optional Flags +### Options | Option | Short | Description | Default | |--------|-------|-------------|---------| -| **OPTIONAL:** `--latitude` | `-lat` | Override latitude center point (use with --longitude) | | -| **OPTIONAL:** `--longitude` | `-long` | Override longitude center point (use with --latitude) | | -| **OPTIONAL:** `--country-label` | | Override country text displayed on poster | | -| **OPTIONAL:** `--theme` | `-t` | Theme name | terracotta | -| **OPTIONAL:** `--distance` | `-d` | Map radius in meters | 18000 | -| **OPTIONAL:** `--list-themes` | | List all available themes | | -| **OPTIONAL:** `--all-themes` | | Generate posters for all available themes | | -| **OPTIONAL:** `--width` | `-W` | Image width in inches | 12 (max: 20) | -| **OPTIONAL:** `--height` | `-H` | Image height in inches | 16 (max: 20) | - -### Multilingual Support - i18n - -Display city and country names in your language with custom fonts from google fonts: +| `--theme` | `-t` | Visual theme | `terracotta` | +| `--distance` | `-d` | Map radius in meters | `18000` | +| `--width` | `-W` | Poster width in inches | `12` | +| `--height` | `-H` | Poster height in inches | `16` | +| `--format` | `-f` | Output format (`png`, `svg`, `pdf`) | `png` | +| `--no-text` | | Generate poster without any text overlay | | +| `--latitude` | `-lat` | Override center latitude | | +| `--longitude` | `-long` | Override center longitude | | +| `--country-label` | | Override country text on poster | | +| `--all-themes` | | Generate posters for all themes | | +| `--list-themes` | | List available themes | | + +### Multilingual Support + +Display city and country names in any language with Google Fonts: | Option | Short | Description | |--------|-------|-------------| -| `--display-city` | `-dc` | Custom display name for city (e.g., "東京") | -| `--display-country` | `-dC` | Custom display name for country (e.g., "日本") | -| `--font-family` | | Google Fonts family name (e.g., "Noto Sans JP") | - -**Examples:** +| `--display-city` | `-dc` | Custom display name for city | +| `--display-country` | `-dC` | Custom display name for country | +| `--font-family` | | Google Fonts family name | ```bash # Japanese -python create_map_poster.py -c "Tokyo" -C "Japan" -dc "東京" -dC "日本" --font-family "Noto Sans JP" +opencartograph -c "Tokyo" -C "Japan" -dc "東京" -dC "日本" --font-family "Noto Sans JP" -t japanese_ink # Korean -python create_map_poster.py -c "Seoul" -C "South Korea" -dc "서울" -dC "대한민국" --font-family "Noto Sans KR" +opencartograph -c "Seoul" -C "South Korea" -dc "서울" -dC "대한민국" --font-family "Noto Sans KR" -t midnight_blue # Arabic -python create_map_poster.py -c "Dubai" -C "UAE" -dc "دبي" -dC "الإمارات" --font-family "Cairo" +opencartograph -c "Dubai" -C "UAE" -dc "دبي" -dC "الإمارات" --font-family "Cairo" -t terracotta ``` -**Note**: Fonts are automatically downloaded from Google Fonts and cached locally in `fonts/cache/`. +Fonts are automatically downloaded from Google Fonts and cached locally in `fonts/cache/`. -### Resolution Guide (300 DPI) - -Use these values for `-W` and `-H` to target specific resolutions: - -| Target | Resolution (px) | Inches (-W / -H) | -|--------|-----------------|------------------| -| **Instagram Post** | 1080 x 1080 | 3.6 x 3.6 | -| **Mobile Wallpaper** | 1080 x 1920 | 3.6 x 6.4 | -| **HD Wallpaper** | 1920 x 1080 | 6.4 x 3.6 | -| **4K Wallpaper** | 3840 x 2160 | 12.8 x 7.2 | -| **A4 Print** | 2480 x 3508 | 8.3 x 11.7 | - -### Usage Examples - -#### Basic Examples +### Examples ```bash -# Simple usage with default theme -python create_map_poster.py -c "Paris" -C "France" +# Map only, no text +opencartograph -c "Frisco" -C "USA" -t noir -d 6000 --no-text -# With custom theme and distance -python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 -``` - -#### Multilingual Examples (Non-Latin Scripts) - -Display city names in their native scripts: - -```bash -# Japanese -python create_map_poster.py -c "Tokyo" -C "Japan" -dc "東京" -dC "日本" --font-family "Noto Sans JP" -t japanese_ink - -# Korean -python create_map_poster.py -c "Seoul" -C "South Korea" -dc "서울" -dC "대한민국" --font-family "Noto Sans KR" -t midnight_blue - -# Thai -python create_map_poster.py -c "Bangkok" -C "Thailand" -dc "กรุงเทพมหานคร" -dC "ประเทศไทย" --font-family "Noto Sans Thai" -t sunset - -# Arabic -python create_map_poster.py -c "Dubai" -C "UAE" -dc "دبي" -dC "الإمارات" --font-family "Cairo" -t terracotta - -# Chinese (Simplified) -python create_map_poster.py -c "Beijing" -C "China" -dc "北京" -dC "中国" --font-family "Noto Sans SC" - -# Khmer -python create_map_poster.py -c "Phnom Penh" -C "Cambodia" -dc "ភ្នំពេញ" -dC "កម្ពុជា" --font-family "Noto Sans Khmer" -``` - -#### Advanced Examples - -```bash # Iconic grid patterns -python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid -python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district - -# Waterfront & canals -python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network -python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals -python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline - -# Radial patterns -python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards -python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads - -# Organic old cities -python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets -python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze -python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient layout +opencartograph -c "New York" -C "USA" -t noir -d 12000 +opencartograph -c "Barcelona" -C "Spain" -t warm_beige -d 8000 -# Coastal cities -python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid -python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city -python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula +# Waterfront cities +opencartograph -c "Venice" -C "Italy" -t blueprint -d 4000 +opencartograph -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 -# River cities -python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves -python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split +# Custom coordinates +opencartograph -c "Davisburg" -C "USA" -lat 42.755243 -long -83.556055 -t noir -d 3000 --no-text -# Override center coordinates -python create_map_poster.py --city "New York" --country "USA" -lat 40.776676 -long -73.971321 -t noir +# SVG output for editing +opencartograph -c "London" -C "UK" -t noir -d 15000 -f svg -# List available themes -python create_map_poster.py --list-themes - -# Generate posters for every theme -python create_map_poster.py -c "Tokyo" -C "Japan" --all-themes +# Generate all themes at once +opencartograph -c "Tokyo" -C "Japan" --all-themes ``` ### Distance Guide @@ -198,17 +132,26 @@ python create_map_poster.py -c "Tokyo" -C "Japan" --all-themes | Distance | Best for | |----------|----------| | 4000-6000m | Small/dense cities (Venice, Amsterdam center) | -| 8000-12000m | Medium cities, focused downtown (Paris, Barcelona) | +| 8000-12000m | Medium cities, downtown focus (Paris, Barcelona) | | 15000-20000m | Large metros, full city view (Tokyo, Mumbai) | +### Resolution Guide (300 DPI) + +| Target | Resolution | Inches (`-W` / `-H`) | +|--------|-----------|----------------------| +| Instagram Post | 1080 x 1080 | 3.6 x 3.6 | +| Mobile Wallpaper | 1080 x 1920 | 3.6 x 6.4 | +| HD Wallpaper | 1920 x 1080 | 6.4 x 3.6 | +| 4K Wallpaper | 3840 x 2160 | 12.8 x 7.2 | +| A4 Print | 2480 x 3508 | 8.3 x 11.7 | + ## Themes -17 themes available in `themes/` directory: +17 built-in themes in the `themes/` directory: | Theme | Style | |-------|-------| -| `gradient_roads` | Smooth gradient shading | -| `contrast_zones` | High contrast urban density | +| `terracotta` | Mediterranean warmth (default) | | `noir` | Pure black background, white roads | | `midnight_blue` | Navy background with gold roads | | `blueprint` | Architectural blueprint aesthetic | @@ -216,26 +159,19 @@ python create_map_poster.py -c "Tokyo" -C "Japan" --all-themes | `warm_beige` | Vintage sepia tones | | `pastel_dream` | Soft muted pastels | | `japanese_ink` | Minimalist ink wash style | -| `emerald` | Lush dark green aesthetic | +| `emerald` | Lush dark green aesthetic | | `forest` | Deep greens and sage | | `ocean` | Blues and teals for coastal cities | -| `terracotta` | Mediterranean warmth | | `sunset` | Warm oranges and pinks | | `autumn` | Seasonal burnt oranges and reds | | `copper_patina` | Oxidized copper aesthetic | | `monochrome_blue` | Single blue color family | +| `gradient_roads` | Smooth gradient shading | +| `contrast_zones` | High contrast urban density | -## Output - -Posters are saved to `posters/` directory with format: - -```text -{city}_{theme}_{YYYYMMDD_HHMMSS}.png -``` - -## Adding Custom Themes +### Custom Themes -Create a JSON file in `themes/` directory: +Create a JSON file in `themes/`: ```json { @@ -255,145 +191,44 @@ Create a JSON file in `themes/` directory: } ``` -## Project Structure - -```text -map_poster/ -├── create_map_poster.py # Main script -├── font_management.py # Font loading and Google Fonts integration -├── themes/ # Theme JSON files -├── fonts/ # Font files -│ ├── Roboto-*.ttf # Default Roboto fonts -│ └── cache/ # Downloaded Google Fonts (auto-generated) -├── posters/ # Generated posters -└── README.md -``` - - -## Hacker's Guide - -Quick reference for contributors who want to extend or modify the script. - -### Contributors Guide - -- Bug fixes are welcomed -- Don't submit user interface (web/desktop) -- Don't Dockerize for now -- If you vibe code any fix please test it and see before and after version of poster -- Before embarking on a big feature please ask in Discussions/Issue if it will be merged - -### Architecture Overview - -```text -┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ CLI Parser │────▶│ Geocoding │────▶│ Data Fetching │ -│ (argparse) │ │ (Nominatim) │ │ (OSMnx) │ -└─────────────────┘ └──────────────┘ └─────────────────┘ - │ - ┌──────────────┐ ▼ - │ Output │◀────┌─────────────────┐ - │ (matplotlib)│ │ Rendering │ - └──────────────┘ │ (matplotlib) │ - └─────────────────┘ -``` - -### Key Functions - -| Function | Purpose | Modify when... | -|----------|---------|----------------| -| `get_coordinates()` | City → lat/lon via Nominatim | Switching geocoding provider | -| `create_poster()` | Main rendering pipeline | Adding new map layers | -| `get_edge_colors_by_type()` | Road color by OSM highway tag | Changing road styling | -| `get_edge_widths_by_type()` | Road width by importance | Adjusting line weights | -| `create_gradient_fade()` | Top/bottom fade effect | Modifying gradient overlay | -| `load_theme()` | JSON theme → dict | Adding new theme properties | -| `is_latin_script()` | Detects script for typography | Supporting new scripts | -| `load_fonts()` | Load custom/default fonts | Changing font loading logic | - -### Rendering Layers (z-order) - -```text -z=11 Text labels (city, country, coords) -z=10 Gradient fades (top & bottom) -z=3 Roads (via ox.plot_graph) -z=2 Parks (green polygons) -z=1 Water (blue polygons) -z=0 Background color -``` - -### OSM Highway Types → Road Hierarchy - -```python -# In get_edge_colors_by_type() and get_edge_widths_by_type() -motorway, motorway_link → Thickest (1.2), darkest -trunk, primary → Thick (1.0) -secondary → Medium (0.8) -tertiary → Thin (0.6) -residential, living_street → Thinnest (0.4), lightest -``` - -### Typography & Script Detection - -The script automatically detects text scripts to apply appropriate typography: - -- **Latin scripts** (English, French, Spanish, etc.): Letter spacing applied for elegant "P A R I S" effect -- **Non-Latin scripts** (Japanese, Arabic, Thai, Korean, etc.): Natural spacing for "東京" (no gaps between characters) - -Script detection uses Unicode ranges (U+0000-U+024F for Latin). If >80% of alphabetic characters are Latin, spacing is applied. - -### Adding New Features - -**New map layer (e.g., railways):** +## Output -```python -# In create_poster(), after parks fetch: -try: - railways = ox.features_from_point(point, tags={'railway': 'rail'}, dist=dist) -except: - railways = None +Posters are saved to `posters/` with the filename format: -# Then plot before roads: -if railways is not None and not railways.empty: - railways = railways.to_crs(g_proj.graph["crs"]) - railways.plot(ax=ax, color=THEME['railway'], linewidth=0.5, zorder=2.5) ``` - -**New theme property:** - -1. Add to theme JSON: `"railway": "#FF0000"` -2. Use in code: `THEME['railway']` -3. Add fallback in `load_theme()` default dict - -### Typography Positioning - -All text uses `transform=ax.transAxes` (0-1 normalized coordinates): - -```text -y=0.14 City name (spaced letters for Latin scripts) -y=0.125 Decorative line -y=0.10 Country name -y=0.07 Coordinates -y=0.02 Attribution (bottom-right) +{city}_{theme}_{YYYYMMDD_HHMMSS}.{format} ``` -### Useful OSMnx Patterns - -```python -# Get all buildings -buildings = ox.features_from_point(point, tags={'building': True}, dist=dist) - -# Get specific amenities -cafes = ox.features_from_point(point, tags={'amenity': 'cafe'}, dist=dist) +## Project Structure -# Different network types -G = ox.graph_from_point(point, dist=dist, network_type='drive') # roads only -G = ox.graph_from_point(point, dist=dist, network_type='bike') # bike paths -G = ox.graph_from_point(point, dist=dist, network_type='walk') # pedestrian ``` - -### Performance Tips - -- Large `dist` values (>20km) = slow downloads + memory heavy -- Cache coordinates locally to avoid Nominatim rate limits -- Use `network_type='drive'` instead of `'all'` for faster renders -- Reduce `dpi` from 300 to 150 for quick previews +OpenCartograph/ +├── opencartograph/ +│ ├── __init__.py +│ ├── __main__.py # python -m opencartograph +│ ├── cli.py # Argument parsing and entry point +│ ├── constants.py # All configurable constants +│ ├── models.py # Frozen dataclasses (Theme, PosterConfig, etc.) +│ ├── fonts.py # Font loading and Google Fonts integration +│ ├── geocoding.py # City name to coordinates via Nominatim +│ ├── osm.py # OpenStreetMap data fetching via OSMnx +│ ├── output.py # File saving and filename generation +│ ├── road_styles.py # Highway classification and styling +│ ├── text.py # Script detection and typography +│ ├── theme.py # Theme loading and management +│ └── rendering/ +│ ├── __init__.py +│ ├── pipeline.py # Rendering orchestrator +│ ├── layers.py # Individual render layers (water, parks, roads, etc.) +│ └── viewport.py # Figure setup and crop calculations +├── tests/ # 85 unit tests +├── themes/ # Theme JSON files +├── fonts/ # Bundled Roboto fonts + Google Fonts cache +└── posters/ # Generated output +``` + +## Contributing + +See [the roadmap](https://github.com/rramboer/OpenCartograph/issues/1) for planned features. + +Fork of [originalankur/maptoposter](https://github.com/originalankur/maptoposter). Licensed under MIT. diff --git a/create_map_poster.py b/create_map_poster.py deleted file mode 100755 index 3eab412b2..000000000 --- a/create_map_poster.py +++ /dev/null @@ -1,1051 +0,0 @@ -#!/usr/bin/env python3 -""" -City Map Poster Generator - -This module generates beautiful, minimalist map posters for any city in the world. -It fetches OpenStreetMap data using OSMnx, applies customizable themes, and creates -high-quality poster-ready images with roads, water features, and parks. -""" - -import argparse -import asyncio -import json -import os -import pickle -import sys -import time -from datetime import datetime -from pathlib import Path -from typing import cast - -import matplotlib.colors as mcolors -import matplotlib.pyplot as plt -import numpy as np -import osmnx as ox -from geopandas import GeoDataFrame -from geopy.geocoders import Nominatim -from lat_lon_parser import parse -from matplotlib.font_manager import FontProperties -from networkx import MultiDiGraph -from shapely.geometry import Point -from tqdm import tqdm - -from font_management import load_fonts - - -class CacheError(Exception): - """Raised when a cache operation fails.""" - - -CACHE_DIR_PATH = os.environ.get("CACHE_DIR", "cache") -CACHE_DIR = Path(CACHE_DIR_PATH) -CACHE_DIR.mkdir(exist_ok=True) - -THEMES_DIR = "themes" -FONTS_DIR = "fonts" -POSTERS_DIR = "posters" - -FILE_ENCODING = "utf-8" - -FONTS = load_fonts() - - -def _cache_path(key: str) -> str: - """ - Generate a safe cache file path from a cache key. - - Args: - key: Cache key identifier - - Returns: - Path to cache file with .pkl extension - """ - safe = key.replace(os.sep, "_") - return os.path.join(CACHE_DIR, f"{safe}.pkl") - - -def cache_get(key: str): - """ - Retrieve a cached object by key. - - Args: - key: Cache key identifier - - Returns: - Cached object if found, None otherwise - - Raises: - CacheError: If cache read operation fails - """ - try: - path = _cache_path(key) - if not os.path.exists(path): - return None - with open(path, "rb") as f: - return pickle.load(f) - except Exception as e: - raise CacheError(f"Cache read failed: {e}") from e - - -def cache_set(key: str, value): - """ - Store an object in the cache. - - Args: - key: Cache key identifier - value: Object to cache (must be picklable) - - Raises: - CacheError: If cache write operation fails - """ - try: - if not os.path.exists(CACHE_DIR): - os.makedirs(CACHE_DIR) - path = _cache_path(key) - with open(path, "wb") as f: - pickle.dump(value, f, protocol=pickle.HIGHEST_PROTOCOL) - except Exception as e: - raise CacheError(f"Cache write failed: {e}") from e - - -# Font loading now handled by font_management.py module - - -def is_latin_script(text): - """ - Check if text is primarily Latin script. - Used to determine if letter-spacing should be applied to city names. - - :param text: Text to analyze - :return: True if text is primarily Latin script, False otherwise - """ - if not text: - return True - - latin_count = 0 - total_alpha = 0 - - for char in text: - if char.isalpha(): - total_alpha += 1 - # Latin Unicode ranges: - # - Basic Latin: U+0000 to U+007F - # - Latin-1 Supplement: U+0080 to U+00FF - # - Latin Extended-A: U+0100 to U+017F - # - Latin Extended-B: U+0180 to U+024F - if ord(char) < 0x250: - latin_count += 1 - - # If no alphabetic characters, default to Latin (numbers, symbols, etc.) - if total_alpha == 0: - return True - - # Consider it Latin if >80% of alphabetic characters are Latin - return (latin_count / total_alpha) > 0.8 - - -def generate_output_filename(city, theme_name, output_format): - """ - Generate unique output filename with city, theme, and datetime. - """ - if not os.path.exists(POSTERS_DIR): - os.makedirs(POSTERS_DIR) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - city_slug = city.lower().replace(" ", "_") - ext = output_format.lower() - filename = f"{city_slug}_{theme_name}_{timestamp}.{ext}" - return os.path.join(POSTERS_DIR, filename) - - -def get_available_themes(): - """ - Scans the themes directory and returns a list of available theme names. - """ - if not os.path.exists(THEMES_DIR): - os.makedirs(THEMES_DIR) - return [] - - themes = [] - for file in sorted(os.listdir(THEMES_DIR)): - if file.endswith(".json"): - theme_name = file[:-5] # Remove .json extension - themes.append(theme_name) - return themes - - -def load_theme(theme_name="terracotta"): - """ - Load theme from JSON file in themes directory. - """ - theme_file = os.path.join(THEMES_DIR, f"{theme_name}.json") - - if not os.path.exists(theme_file): - print(f"⚠ Theme file '{theme_file}' not found. Using default terracotta theme.") - # Fallback to embedded terracotta theme - return { - "name": "Terracotta", - "description": "Mediterranean warmth - burnt orange and clay tones on cream", - "bg": "#F5EDE4", - "text": "#8B4513", - "gradient_color": "#F5EDE4", - "water": "#A8C4C4", - "parks": "#E8E0D0", - "road_motorway": "#A0522D", - "road_primary": "#B8653A", - "road_secondary": "#C9846A", - "road_tertiary": "#D9A08A", - "road_residential": "#E5C4B0", - "road_default": "#D9A08A", - } - - with open(theme_file, "r", encoding=FILE_ENCODING) as f: - theme = json.load(f) - print(f"✓ Loaded theme: {theme.get('name', theme_name)}") - if "description" in theme: - print(f" {theme['description']}") - return theme - - -# Load theme (can be changed via command line or input) -THEME = dict[str, str]() # Will be loaded later - - -def create_gradient_fade(ax, color, location="bottom", zorder=10): - """ - Creates a fade effect at the top or bottom of the map. - """ - vals = np.linspace(0, 1, 256).reshape(-1, 1) - gradient = np.hstack((vals, vals)) - - rgb = mcolors.to_rgb(color) - my_colors = np.zeros((256, 4)) - my_colors[:, 0] = rgb[0] - my_colors[:, 1] = rgb[1] - my_colors[:, 2] = rgb[2] - - if location == "bottom": - my_colors[:, 3] = np.linspace(1, 0, 256) - extent_y_start = 0 - extent_y_end = 0.25 - else: - my_colors[:, 3] = np.linspace(0, 1, 256) - extent_y_start = 0.75 - extent_y_end = 1.0 - - custom_cmap = mcolors.ListedColormap(my_colors) - - xlim = ax.get_xlim() - ylim = ax.get_ylim() - y_range = ylim[1] - ylim[0] - - y_bottom = ylim[0] + y_range * extent_y_start - y_top = ylim[0] + y_range * extent_y_end - - ax.imshow( - gradient, - extent=[xlim[0], xlim[1], y_bottom, y_top], - aspect="auto", - cmap=custom_cmap, - zorder=zorder, - origin="lower", - ) - - -def get_edge_colors_by_type(g): - """ - Assigns colors to edges based on road type hierarchy. - Returns a list of colors corresponding to each edge in the graph. - """ - edge_colors = [] - - for _u, _v, data in g.edges(data=True): - # Get the highway type (can be a list or string) - highway = data.get('highway', 'unclassified') - - # Handle list of highway types (take the first one) - if isinstance(highway, list): - highway = highway[0] if highway else 'unclassified' - - # Assign color based on road type - if highway in ["motorway", "motorway_link"]: - color = THEME["road_motorway"] - elif highway in ["trunk", "trunk_link", "primary", "primary_link"]: - color = THEME["road_primary"] - elif highway in ["secondary", "secondary_link"]: - color = THEME["road_secondary"] - elif highway in ["tertiary", "tertiary_link"]: - color = THEME["road_tertiary"] - elif highway in ["residential", "living_street", "unclassified"]: - color = THEME["road_residential"] - else: - color = THEME['road_default'] - - edge_colors.append(color) - - return edge_colors - - -def get_edge_widths_by_type(g): - """ - Assigns line widths to edges based on road type. - Major roads get thicker lines. - """ - edge_widths = [] - - for _u, _v, data in g.edges(data=True): - highway = data.get('highway', 'unclassified') - - if isinstance(highway, list): - highway = highway[0] if highway else 'unclassified' - - # Assign width based on road importance - if highway in ["motorway", "motorway_link"]: - width = 1.2 - elif highway in ["trunk", "trunk_link", "primary", "primary_link"]: - width = 1.0 - elif highway in ["secondary", "secondary_link"]: - width = 0.8 - elif highway in ["tertiary", "tertiary_link"]: - width = 0.6 - else: - width = 0.4 - - edge_widths.append(width) - - return edge_widths - - -def get_coordinates(city, country): - """ - Fetches coordinates for a given city and country using geopy. - Includes rate limiting to be respectful to the geocoding service. - """ - coords = f"coords_{city.lower()}_{country.lower()}" - cached = cache_get(coords) - if cached: - print(f"✓ Using cached coordinates for {city}, {country}") - return cached - - print("Looking up coordinates...") - geolocator = Nominatim(user_agent="city_map_poster", timeout=10) - - # Add a small delay to respect Nominatim's usage policy - time.sleep(1) - - try: - location = geolocator.geocode(f"{city}, {country}") - except Exception as e: - raise ValueError(f"Geocoding failed for {city}, {country}: {e}") from e - - # If geocode returned a coroutine in some environments, run it to get the result. - if asyncio.iscoroutine(location): - try: - location = asyncio.run(location) - except RuntimeError as exc: - # If an event loop is already running, try using it to complete the coroutine. - loop = asyncio.get_event_loop() - if loop.is_running(): - # Running event loop in the same thread; raise a clear error. - raise RuntimeError( - "Geocoder returned a coroutine while an event loop is already running. " - "Run this script in a synchronous environment." - ) from exc - location = loop.run_until_complete(location) - - if location: - # Use getattr to safely access address (helps static analyzers) - addr = getattr(location, "address", None) - if addr: - print(f"✓ Found: {addr}") - else: - print("✓ Found location (address not available)") - print(f"✓ Coordinates: {location.latitude}, {location.longitude}") - try: - cache_set(coords, (location.latitude, location.longitude)) - except CacheError as e: - print(e) - return (location.latitude, location.longitude) - - raise ValueError(f"Could not find coordinates for {city}, {country}") - - -def get_crop_limits(g_proj, center_lat_lon, fig, dist): - """ - Crop inward to preserve aspect ratio while guaranteeing - full coverage of the requested radius. - """ - lat, lon = center_lat_lon - - # Project center point into graph CRS - center = ( - ox.projection.project_geometry( - Point(lon, lat), - crs="EPSG:4326", - to_crs=g_proj.graph["crs"] - )[0] - ) - center_x, center_y = center.x, center.y - - fig_width, fig_height = fig.get_size_inches() - aspect = fig_width / fig_height - - # Start from the *requested* radius - half_x = dist - half_y = dist - - # Cut inward to match aspect - if aspect > 1: # landscape → reduce height - half_y = half_x / aspect - else: # portrait → reduce width - half_x = half_y * aspect - - return ( - (center_x - half_x, center_x + half_x), - (center_y - half_y, center_y + half_y), - ) - - -def fetch_graph(point, dist) -> MultiDiGraph | None: - """ - Fetch street network graph from OpenStreetMap. - - Uses caching to avoid redundant downloads. Fetches all network types - within the specified distance from the center point. - - Args: - point: (latitude, longitude) tuple for center point - dist: Distance in meters from center point - - Returns: - MultiDiGraph of street network, or None if fetch fails - """ - lat, lon = point - graph = f"graph_{lat}_{lon}_{dist}" - cached = cache_get(graph) - if cached is not None: - print("✓ Using cached street network") - return cast(MultiDiGraph, cached) - - try: - g = ox.graph_from_point(point, dist=dist, dist_type='bbox', network_type='all', truncate_by_edge=True) - # Rate limit between requests - time.sleep(0.5) - try: - cache_set(graph, g) - except CacheError as e: - print(e) - return g - except Exception as e: - print(f"OSMnx error while fetching graph: {e}") - return None - - -def fetch_features(point, dist, tags, name) -> GeoDataFrame | None: - """ - Fetch geographic features (water, parks, etc.) from OpenStreetMap. - - Uses caching to avoid redundant downloads. Fetches features matching - the specified OSM tags within distance from center point. - - Args: - point: (latitude, longitude) tuple for center point - dist: Distance in meters from center point - tags: Dictionary of OSM tags to filter features - name: Name for this feature type (for caching and logging) - - Returns: - GeoDataFrame of features, or None if fetch fails - """ - lat, lon = point - tag_str = "_".join(tags.keys()) - features = f"{name}_{lat}_{lon}_{dist}_{tag_str}" - cached = cache_get(features) - if cached is not None: - print(f"✓ Using cached {name}") - return cast(GeoDataFrame, cached) - - try: - data = ox.features_from_point(point, tags=tags, dist=dist) - # Rate limit between requests - time.sleep(0.3) - try: - cache_set(features, data) - except CacheError as e: - print(e) - return data - except Exception as e: - print(f"OSMnx error while fetching features: {e}") - return None - - -def create_poster( - city, - country, - point, - dist, - output_file, - output_format, - width=12, - height=16, - country_label=None, - name_label=None, - display_city=None, - display_country=None, - fonts=None, -): - """ - Generate a complete map poster with roads, water, parks, and typography. - - Creates a high-quality poster by fetching OSM data, rendering map layers, - applying the current theme, and adding text labels with coordinates. - - Args: - city: City name for display on poster - country: Country name for display on poster - point: (latitude, longitude) tuple for map center - dist: Map radius in meters - output_file: Path where poster will be saved - output_format: File format ('png', 'svg', or 'pdf') - width: Poster width in inches (default: 12) - height: Poster height in inches (default: 16) - country_label: Optional override for country text on poster - _name_label: Optional override for city name (unused, reserved for future use) - - Raises: - RuntimeError: If street network data cannot be retrieved - """ - # Handle display names for i18n support - # Priority: display_city/display_country > name_label/country_label > city/country - display_city = display_city or name_label or city - display_country = display_country or country_label or country - - print(f"\nGenerating map for {city}, {country}...") - - # Progress bar for data fetching - with tqdm( - total=3, - desc="Fetching map data", - unit="step", - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}", - ) as pbar: - # 1. Fetch Street Network - pbar.set_description("Downloading street network") - compensated_dist = dist * (max(height, width) / min(height, width)) / 4 # To compensate for viewport crop - g = fetch_graph(point, compensated_dist) - if g is None: - raise RuntimeError("Failed to retrieve street network data.") - pbar.update(1) - - # 2. Fetch Water Features - pbar.set_description("Downloading water features") - water = fetch_features( - point, - compensated_dist, - tags={"natural": ["water", "bay", "strait"], "waterway": "riverbank"}, - name="water", - ) - pbar.update(1) - - # 3. Fetch Parks - pbar.set_description("Downloading parks/green spaces") - parks = fetch_features( - point, - compensated_dist, - tags={"leisure": "park", "landuse": "grass"}, - name="parks", - ) - pbar.update(1) - - print("✓ All data retrieved successfully!") - - # 2. Setup Plot - print("Rendering map...") - fig, ax = plt.subplots(figsize=(width, height), facecolor=THEME["bg"]) - ax.set_facecolor(THEME["bg"]) - ax.set_position((0.0, 0.0, 1.0, 1.0)) - - # Project graph to a metric CRS so distances and aspect are linear (meters) - g_proj = ox.project_graph(g) - - # 3. Plot Layers - # Layer 1: Polygons (filter to only plot polygon/multipolygon geometries, not points) - if water is not None and not water.empty: - # Filter to only polygon/multipolygon geometries to avoid point features showing as dots - water_polys = water[water.geometry.type.isin(["Polygon", "MultiPolygon"])] - if not water_polys.empty: - # Project water features in the same CRS as the graph - try: - water_polys = ox.projection.project_gdf(water_polys) - except Exception: - water_polys = water_polys.to_crs(g_proj.graph['crs']) - water_polys.plot(ax=ax, facecolor=THEME['water'], edgecolor='none', zorder=0.5) - - if parks is not None and not parks.empty: - # Filter to only polygon/multipolygon geometries to avoid point features showing as dots - parks_polys = parks[parks.geometry.type.isin(["Polygon", "MultiPolygon"])] - if not parks_polys.empty: - # Project park features in the same CRS as the graph - try: - parks_polys = ox.projection.project_gdf(parks_polys) - except Exception: - parks_polys = parks_polys.to_crs(g_proj.graph['crs']) - parks_polys.plot(ax=ax, facecolor=THEME['parks'], edgecolor='none', zorder=0.8) - # Layer 2: Roads with hierarchy coloring - print("Applying road hierarchy colors...") - edge_colors = get_edge_colors_by_type(g_proj) - edge_widths = get_edge_widths_by_type(g_proj) - - # Determine cropping limits to maintain the poster aspect ratio - crop_xlim, crop_ylim = get_crop_limits(g_proj, point, fig, compensated_dist) - # Plot the projected graph and then apply the cropped limits - ox.plot_graph( - g_proj, ax=ax, bgcolor=THEME['bg'], - node_size=0, - edge_color=edge_colors, - edge_linewidth=edge_widths, - show=False, - close=False, - ) - ax.set_aspect("equal", adjustable="box") - ax.set_xlim(crop_xlim) - ax.set_ylim(crop_ylim) - - # Layer 3: Gradients (Top and Bottom) - create_gradient_fade(ax, THEME['gradient_color'], location='bottom', zorder=10) - create_gradient_fade(ax, THEME['gradient_color'], location='top', zorder=10) - - # Calculate scale factor based on smaller dimension (reference 12 inches) - # This ensures text scales properly for both portrait and landscape orientations - scale_factor = min(height, width) / 12.0 - - # Base font sizes (at 12 inches width) - base_main = 60 - base_sub = 22 - base_coords = 14 - base_attr = 8 - - # 4. Typography - use custom fonts if provided, otherwise use default FONTS - active_fonts = fonts or FONTS - if active_fonts: - # font_main is calculated dynamically later based on length - font_sub = FontProperties( - fname=active_fonts["light"], size=base_sub * scale_factor - ) - font_coords = FontProperties( - fname=active_fonts["regular"], size=base_coords * scale_factor - ) - font_attr = FontProperties( - fname=active_fonts["light"], size=base_attr * scale_factor - ) - else: - # Fallback to system fonts - font_sub = FontProperties( - family="monospace", weight="normal", size=base_sub * scale_factor - ) - font_coords = FontProperties( - family="monospace", size=base_coords * scale_factor - ) - font_attr = FontProperties(family="monospace", size=base_attr * scale_factor) - - # Format city name based on script type - # Latin scripts: apply uppercase and letter spacing for aesthetic - # Non-Latin scripts (CJK, Thai, Arabic, etc.): no spacing, preserve case structure - if is_latin_script(display_city): - # Latin script: uppercase with letter spacing (e.g., "P A R I S") - spaced_city = " ".join(list(display_city.upper())) - else: - # Non-Latin script: no spacing, no forced uppercase - # For scripts like Arabic, Thai, Japanese, etc. - spaced_city = display_city - - # Dynamically adjust font size based on city name length to prevent truncation - # We use the already scaled "main" font size as the starting point. - base_adjusted_main = base_main * scale_factor - city_char_count = len(display_city) - - # Heuristic: If length is > 10, start reducing. - if city_char_count > 10: - length_factor = 10 / city_char_count - adjusted_font_size = max(base_adjusted_main * length_factor, 10 * scale_factor) - else: - adjusted_font_size = base_adjusted_main - - if active_fonts: - font_main_adjusted = FontProperties( - fname=active_fonts["bold"], size=adjusted_font_size - ) - else: - font_main_adjusted = FontProperties( - family="monospace", weight="bold", size=adjusted_font_size - ) - - # --- BOTTOM TEXT --- - ax.text( - 0.5, - 0.14, - spaced_city, - transform=ax.transAxes, - color=THEME["text"], - ha="center", - fontproperties=font_main_adjusted, - zorder=11, - ) - - ax.text( - 0.5, - 0.10, - display_country.upper(), - transform=ax.transAxes, - color=THEME["text"], - ha="center", - fontproperties=font_sub, - zorder=11, - ) - - lat, lon = point - coords = ( - f"{lat:.4f}° N / {lon:.4f}° E" - if lat >= 0 - else f"{abs(lat):.4f}° S / {lon:.4f}° E" - ) - if lon < 0: - coords = coords.replace("E", "W") - - ax.text( - 0.5, - 0.07, - coords, - transform=ax.transAxes, - color=THEME["text"], - alpha=0.7, - ha="center", - fontproperties=font_coords, - zorder=11, - ) - - ax.plot( - [0.4, 0.6], - [0.125, 0.125], - transform=ax.transAxes, - color=THEME["text"], - linewidth=1 * scale_factor, - zorder=11, - ) - - # --- ATTRIBUTION (bottom right) --- - if FONTS: - font_attr = FontProperties(fname=FONTS["light"], size=8) - else: - font_attr = FontProperties(family="monospace", size=8) - - ax.text( - 0.98, - 0.02, - "© OpenStreetMap contributors", - transform=ax.transAxes, - color=THEME["text"], - alpha=0.5, - ha="right", - va="bottom", - fontproperties=font_attr, - zorder=11, - ) - - # 5. Save - print(f"Saving to {output_file}...") - - fmt = output_format.lower() - save_kwargs = dict( - facecolor=THEME["bg"], - bbox_inches="tight", - pad_inches=0.05, - ) - - # DPI matters mainly for raster formats - if fmt == "png": - save_kwargs["dpi"] = 300 - - plt.savefig(output_file, format=fmt, **save_kwargs) - - plt.close() - print(f"✓ Done! Poster saved as {output_file}") - - -def print_examples(): - """Print usage examples.""" - print(""" -City Map Poster Generator -========================= - -Usage: - python create_map_poster.py --city --country [options] - -Examples: - # Iconic grid patterns - python create_map_poster.py -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid - python create_map_poster.py -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid - - # Waterfront & canals - python create_map_poster.py -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network - python create_map_poster.py -c "Amsterdam" -C "Netherlands" -t ocean -d 6000 # Concentric canals - python create_map_poster.py -c "Dubai" -C "UAE" -t midnight_blue -d 15000 # Palm & coastline - - # Radial patterns - python create_map_poster.py -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards - python create_map_poster.py -c "Moscow" -C "Russia" -t noir -d 12000 # Ring roads - - # Organic old cities - python create_map_poster.py -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets - python create_map_poster.py -c "Marrakech" -C "Morocco" -t terracotta -d 5000 # Medina maze - python create_map_poster.py -c "Rome" -C "Italy" -t warm_beige -d 8000 # Ancient street layout - - # Coastal cities - python create_map_poster.py -c "San Francisco" -C "USA" -t sunset -d 10000 # Peninsula grid - python create_map_poster.py -c "Sydney" -C "Australia" -t ocean -d 12000 # Harbor city - python create_map_poster.py -c "Mumbai" -C "India" -t contrast_zones -d 18000 # Coastal peninsula - - # River cities - python create_map_poster.py -c "London" -C "UK" -t noir -d 15000 # Thames curves - python create_map_poster.py -c "Budapest" -C "Hungary" -t copper_patina -d 8000 # Danube split - - # List themes - python create_map_poster.py --list-themes - -Options: - --city, -c City name (required) - --country, -C Country name (required) - --country-label Override country text displayed on poster - --theme, -t Theme name (default: terracotta) - --all-themes Generate posters for all themes - --distance, -d Map radius in meters (default: 18000) - --list-themes List all available themes - -Distance guide: - 4000-6000m Small/dense cities (Venice, Amsterdam old center) - 8000-12000m Medium cities, focused downtown (Paris, Barcelona) - 15000-20000m Large metros, full city view (Tokyo, Mumbai) - -Available themes can be found in the 'themes/' directory. -Generated posters are saved to 'posters/' directory. -""") - - -def list_themes(): - """List all available themes with descriptions.""" - available_themes = get_available_themes() - if not available_themes: - print("No themes found in 'themes/' directory.") - return - - print("\nAvailable Themes:") - print("-" * 60) - for theme_name in available_themes: - theme_path = os.path.join(THEMES_DIR, f"{theme_name}.json") - try: - with open(theme_path, "r", encoding=FILE_ENCODING) as f: - theme_data = json.load(f) - display_name = theme_data.get('name', theme_name) - description = theme_data.get('description', '') - except (OSError, json.JSONDecodeError): - display_name = theme_name - description = "" - print(f" {theme_name}") - print(f" {display_name}") - if description: - print(f" {description}") - print() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Generate beautiful map posters for any city", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python create_map_poster.py --city "New York" --country "USA" - python create_map_poster.py --city "New York" --country "USA" -l 40.776676 -73.971321 --theme neon_cyberpunk - python create_map_poster.py --city Tokyo --country Japan --theme midnight_blue - python create_map_poster.py --city Paris --country France --theme noir --distance 15000 - python create_map_poster.py --list-themes - """, - ) - - parser.add_argument("--city", "-c", type=str, help="City name") - parser.add_argument("--country", "-C", type=str, help="Country name") - parser.add_argument( - "--latitude", - "-lat", - dest="latitude", - type=str, - help="Override latitude center point", - ) - parser.add_argument( - "--longitude", - "-long", - dest="longitude", - type=str, - help="Override longitude center point", - ) - parser.add_argument( - "--country-label", - dest="country_label", - type=str, - help="Override country text displayed on poster", - ) - parser.add_argument( - "--theme", - "-t", - type=str, - default="terracotta", - help="Theme name (default: terracotta)", - ) - parser.add_argument( - "--all-themes", - "--All-themes", - dest="all_themes", - action="store_true", - help="Generate posters for all themes", - ) - parser.add_argument( - "--distance", - "-d", - type=int, - default=18000, - help="Map radius in meters (default: 18000)", - ) - parser.add_argument( - "--width", - "-W", - type=float, - default=12, - help="Image width in inches (default: 12, max: 20 )", - ) - parser.add_argument( - "--height", - "-H", - type=float, - default=16, - help="Image height in inches (default: 16, max: 20)", - ) - parser.add_argument( - "--list-themes", action="store_true", help="List all available themes" - ) - parser.add_argument( - "--display-city", - "-dc", - type=str, - help="Custom display name for city (for i18n support)", - ) - parser.add_argument( - "--display-country", - "-dC", - type=str, - help="Custom display name for country (for i18n support)", - ) - parser.add_argument( - "--font-family", - type=str, - help='Google Fonts family name (e.g., "Noto Sans JP", "Open Sans"). If not specified, uses local Roboto fonts.', - ) - parser.add_argument( - "--format", - "-f", - default="png", - choices=["png", "svg", "pdf"], - help="Output format for the poster (default: png)", - ) - - args = parser.parse_args() - - # If no arguments provided, show examples - if len(sys.argv) == 1: - print_examples() - sys.exit(0) - - # List themes if requested - if args.list_themes: - list_themes() - sys.exit(0) - - # Validate required arguments - if not args.city or not args.country: - print("Error: --city and --country are required.\n") - print_examples() - sys.exit(1) - - # Enforce maximum dimensions - if args.width > 20: - print( - f"⚠ Width {args.width} exceeds the maximum allowed limit of 20. It's enforced as max limit 20." - ) - args.width = 20.0 - if args.height > 20: - print( - f"⚠ Height {args.height} exceeds the maximum allowed limit of 20. It's enforced as max limit 20." - ) - args.height = 20.0 - - available_themes = get_available_themes() - if not available_themes: - print("No themes found in 'themes/' directory.") - sys.exit(1) - - if args.all_themes: - themes_to_generate = available_themes - else: - if args.theme not in available_themes: - print(f"Error: Theme '{args.theme}' not found.") - print(f"Available themes: {', '.join(available_themes)}") - sys.exit(1) - themes_to_generate = [args.theme] - - print("=" * 50) - print("City Map Poster Generator") - print("=" * 50) - - # Load custom fonts if specified - custom_fonts = None - if args.font_family: - custom_fonts = load_fonts(args.font_family) - if not custom_fonts: - print(f"⚠ Failed to load '{args.font_family}', falling back to Roboto") - - # Get coordinates and generate poster - try: - if args.latitude and args.longitude: - lat = parse(args.latitude) - lon = parse(args.longitude) - coords = [lat, lon] - print(f"✓ Coordinates: {', '.join([str(i) for i in coords])}") - else: - coords = get_coordinates(args.city, args.country) - - for theme_name in themes_to_generate: - THEME = load_theme(theme_name) - output_file = generate_output_filename(args.city, theme_name, args.format) - create_poster( - args.city, - args.country, - coords, - args.distance, - output_file, - args.format, - args.width, - args.height, - country_label=args.country_label, - display_city=args.display_city, - display_country=args.display_country, - fonts=custom_fonts, - ) - - print("\n" + "=" * 50) - print("✓ Poster generation complete!") - print("=" * 50) - - except Exception as e: - print(f"\n✗ Error: {e}") - import traceback - - traceback.print_exc() - sys.exit(1) diff --git a/font_management.py b/font_management.py deleted file mode 100644 index 1c738d83d..000000000 --- a/font_management.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -Font Management Module -Handles font loading, Google Fonts integration, and caching. -""" - -import os -import re -from pathlib import Path -from typing import Optional - -import requests - -FONTS_DIR = "fonts" -FONTS_CACHE_DIR = Path(FONTS_DIR) / "cache" - - -def download_google_font(font_family: str, weights: list = None) -> Optional[dict]: - """ - Download a font family from Google Fonts and cache it locally. - Returns dict with font paths for different weights, or None if download fails. - - :param font_family: Google Fonts family name (e.g., 'Noto Sans JP', 'Open Sans') - :param weights: List of font weights to download (300=light, 400=regular, 700=bold) - :return: Dict with 'light', 'regular', 'bold' keys mapping to font file paths - """ - if weights is None: - weights = [300, 400, 700] - - # Create fonts cache directory - FONTS_CACHE_DIR.mkdir(parents=True, exist_ok=True) - - # Normalize font family name for file paths - font_name_safe = font_family.replace(" ", "_").lower() - - font_files = {} - - try: - # Google Fonts API endpoint - request all weights at once - weights_str = ";".join(map(str, weights)) - api_url = "https://fonts.googleapis.com/css2" - - # Use requests library for cleaner HTTP handling - params = {"family": f"{font_family}:wght@{weights_str}"} - headers = { - "User-Agent": "Mozilla/5.0" # Get .woff2 files (better compression) - } - - # Fetch CSS file - response = requests.get(api_url, params=params, headers=headers, timeout=10) - response.raise_for_status() - css_content = response.text - - # Parse CSS to extract weight-specific URLs - # Google Fonts CSS has @font-face blocks with font-weight and src: url() - weight_url_map = {} - - # Split CSS into font-face blocks - font_face_blocks = re.split(r"@font-face\s*\{", css_content) - - for block in font_face_blocks[1:]: # Skip first empty split - # Extract font-weight - weight_match = re.search(r"font-weight:\s*(\d+)", block) - if not weight_match: - continue - - weight = int(weight_match.group(1)) - - # Extract URL (prefer woff2, fallback to ttf) - url_match = re.search(r"url\((https://[^)]+\.(woff2|ttf))\)", block) - if url_match: - weight_url_map[weight] = url_match.group(1) - - # Map weights to our keys - weight_map = {300: "light", 400: "regular", 700: "bold"} - - # Download each weight - for weight in weights: - weight_key = weight_map.get(weight, "regular") - - # Find URL for this weight - weight_url = weight_url_map.get(weight) - - # If exact weight not found, try to find closest - if not weight_url and weight_url_map: - # Find closest weight - closest_weight = min( - weight_url_map.keys(), key=lambda x: abs(x - weight) - ) - weight_url = weight_url_map[closest_weight] - print( - f" Using weight {closest_weight} for {weight_key} (requested {weight} not available)" - ) - - if weight_url: - # Determine file extension - file_ext = "woff2" if weight_url.endswith(".woff2") else "ttf" - - # Download font file - font_filename = f"{font_name_safe}_{weight_key}.{file_ext}" - font_path = FONTS_CACHE_DIR / font_filename - - if not font_path.exists(): - print(f" Downloading {font_family} {weight_key} ({weight})...") - try: - font_response = requests.get(weight_url, timeout=10) - font_response.raise_for_status() - font_path.write_bytes(font_response.content) - except Exception as e: - print(f" ⚠ Failed to download {weight_key}: {e}") - continue - else: - print(f" Using cached {font_family} {weight_key}") - - font_files[weight_key] = str(font_path) - - # Ensure we have at least regular weight - if "regular" not in font_files and font_files: - # Use first available as regular - font_files["regular"] = list(font_files.values())[0] - print(f" Using {list(font_files.keys())[0]} weight as regular") - - # If we don't have all three weights, duplicate available ones - if "bold" not in font_files and "regular" in font_files: - font_files["bold"] = font_files["regular"] - print(" Using regular weight as bold") - if "light" not in font_files and "regular" in font_files: - font_files["light"] = font_files["regular"] - print(" Using regular weight as light") - - return font_files if font_files else None - - except Exception as e: - print(f"⚠ Error downloading Google Font '{font_family}': {e}") - return None - - -def load_fonts(font_family: Optional[str] = None) -> Optional[dict]: - """ - Load fonts from local directory or download from Google Fonts. - Returns dict with font paths for different weights. - - :param font_family: Google Fonts family name (e.g., 'Noto Sans JP', 'Open Sans'). - If None, uses local Roboto fonts. - :return: Dict with 'bold', 'regular', 'light' keys mapping to font file paths, - or None if all loading methods fail - """ - # If custom font family specified, try to download from Google Fonts - if font_family and font_family.lower() != "roboto": - print(f"Loading Google Font: {font_family}") - fonts = download_google_font(font_family) - if fonts: - print(f"✓ Font '{font_family}' loaded successfully") - return fonts - - print(f"⚠ Failed to load '{font_family}', falling back to local Roboto") - - # Default: Load local Roboto fonts - fonts = { - "bold": os.path.join(FONTS_DIR, "Roboto-Bold.ttf"), - "regular": os.path.join(FONTS_DIR, "Roboto-Regular.ttf"), - "light": os.path.join(FONTS_DIR, "Roboto-Light.ttf"), - } - - # Verify fonts exist - for _weight, path in fonts.items(): - if not os.path.exists(path): - print(f"⚠ Font not found: {path}") - return None - - return fonts diff --git a/opencartograph/__init__.py b/opencartograph/__init__.py new file mode 100644 index 000000000..52357e157 --- /dev/null +++ b/opencartograph/__init__.py @@ -0,0 +1,8 @@ +""" +OpenCartograph + +An open-source cartographic rendering tool. Generate high-quality map +visualizations for any location worldwide using OpenStreetMap data. +""" + +__version__ = "0.4.0" diff --git a/opencartograph/__main__.py b/opencartograph/__main__.py new file mode 100644 index 000000000..f55e11115 --- /dev/null +++ b/opencartograph/__main__.py @@ -0,0 +1,7 @@ +"""Enable running opencartograph as a module: python -m opencartograph""" + +import sys + +from opencartograph.cli import main + +sys.exit(main()) diff --git a/opencartograph/cache.py b/opencartograph/cache.py new file mode 100644 index 000000000..6aa2c29c9 --- /dev/null +++ b/opencartograph/cache.py @@ -0,0 +1,79 @@ +""" +Pickle-based caching for expensive API calls (geocoding, OSM data). +""" + +from __future__ import annotations + +import os +import pickle +from pathlib import Path +from typing import Any + +from . import constants + + +class CacheError(Exception): + """Raised when a cache operation fails.""" + + +def _cache_path(key: str, cache_dir: Path | None = None) -> str: + """ + Generate a safe cache file path from a cache key. + + Args: + key: Cache key identifier + cache_dir: Override cache directory (defaults to constants.CACHE_DIR) + + Returns: + Path to cache file with .pkl extension + """ + cache_dir = cache_dir or constants.CACHE_DIR + safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in key) + return os.path.join(cache_dir, f"{safe}.pkl") + + +def cache_get(key: str, cache_dir: Path | None = None) -> Any | None: + """ + Retrieve a cached object by key. + + Args: + key: Cache key identifier + cache_dir: Override cache directory + + Returns: + Cached object if found, None otherwise + + Raises: + CacheError: If cache read operation fails + """ + try: + path = _cache_path(key, cache_dir) + if not os.path.exists(path): + return None + with open(path, "rb") as f: + return pickle.load(f) + except Exception as e: + raise CacheError(f"Cache read failed: {e}") from e + + +def cache_set(key: str, value: Any, cache_dir: Path | None = None) -> None: + """ + Store an object in the cache. + + Args: + key: Cache key identifier + value: Object to cache (must be picklable) + cache_dir: Override cache directory + + Raises: + CacheError: If cache write operation fails + """ + try: + cache_dir = cache_dir or constants.CACHE_DIR + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + path = _cache_path(key, cache_dir) + with open(path, "wb") as f: + pickle.dump(value, f, protocol=pickle.HIGHEST_PROTOCOL) + except Exception as e: + raise CacheError(f"Cache write failed: {e}") from e diff --git a/opencartograph/cli.py b/opencartograph/cli.py new file mode 100644 index 000000000..eb2307f49 --- /dev/null +++ b/opencartograph/cli.py @@ -0,0 +1,268 @@ +""" +Command-line interface for OpenCartograph. +""" + +from __future__ import annotations + +import argparse +import sys +import traceback + +from lat_lon_parser import parse + +from . import constants +from .fonts import load_fonts +from .geocoding import get_coordinates +from .models import Coordinates, PosterConfig +from .output import generate_output_filename +from .rendering import compose_poster +from .theme import get_available_themes, list_themes, load_theme + + +def print_examples() -> None: + """Print usage examples.""" + print(""" +OpenCartograph +============== + +Usage: + opencartograph --city --country [options] + +Examples: + opencartograph -c "New York" -C "USA" -t noir -d 12000 # Manhattan grid + opencartograph -c "Barcelona" -C "Spain" -t warm_beige -d 8000 # Eixample district grid + opencartograph -c "Venice" -C "Italy" -t blueprint -d 4000 # Canal network + opencartograph -c "Paris" -C "France" -t pastel_dream -d 10000 # Haussmann boulevards + opencartograph -c "Tokyo" -C "Japan" -t japanese_ink -d 15000 # Dense organic streets + opencartograph -c "London" -C "UK" -t noir -d 15000 # Thames curves + opencartograph -c "Frisco" -C "USA" -t noir -d 6000 --no-text # Map only, no text + +Options: + --city, -c City name (required) + --country, -C Country name (required) + --country-label Override country text displayed on poster + --theme, -t Theme name (default: terracotta) + --all-themes Generate posters for all themes + --distance, -d Map radius in meters (default: 18000) + --no-text Generate poster without any text overlay + --list-themes List all available themes + +Distance guide: + 4000-6000m Small/dense cities (Venice, Amsterdam old center) + 8000-12000m Medium cities, focused downtown (Paris, Barcelona) + 15000-20000m Large metros, full city view (Tokyo, Mumbai) + +Available themes can be found in the 'themes/' directory. +Generated posters are saved to 'posters/' directory. +""") + + +def build_parser() -> argparse.ArgumentParser: + """ + Build the argument parser with all CLI options. + + Returns: + Configured ArgumentParser + """ + parser = argparse.ArgumentParser( + description="OpenCartograph - Generate high-quality map visualizations", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + opencartograph --city "New York" --country "USA" + opencartograph --city Tokyo --country Japan --theme midnight_blue + opencartograph --city Paris --country France --theme noir --distance 15000 + opencartograph --city Frisco --country USA --theme noir --no-text -d 6000 + opencartograph --list-themes + """, + ) + + parser.add_argument("--city", "-c", type=str, help="City name") + parser.add_argument("--country", "-C", type=str, help="Country name") + parser.add_argument( + "--latitude", "-lat", dest="latitude", type=str, + help="Override latitude center point", + ) + parser.add_argument( + "--longitude", "-long", dest="longitude", type=str, + help="Override longitude center point", + ) + parser.add_argument( + "--country-label", dest="country_label", type=str, + help="Override country text displayed on poster", + ) + parser.add_argument( + "--theme", "-t", type=str, default=constants.DEFAULT_THEME, + help=f"Theme name (default: {constants.DEFAULT_THEME})", + ) + parser.add_argument( + "--all-themes", "--All-themes", dest="all_themes", action="store_true", + help="Generate posters for all themes", + ) + parser.add_argument( + "--distance", "-d", type=int, default=constants.DEFAULT_DISTANCE, + help=f"Map radius in meters (default: {constants.DEFAULT_DISTANCE})", + ) + parser.add_argument( + "--width", "-W", type=float, default=constants.DEFAULT_WIDTH, + help=f"Image width in inches (default: {constants.DEFAULT_WIDTH}, max: {constants.MAX_DIMENSION_INCHES})", + ) + parser.add_argument( + "--height", "-H", type=float, default=constants.DEFAULT_HEIGHT, + help=f"Image height in inches (default: {constants.DEFAULT_HEIGHT}, max: {constants.MAX_DIMENSION_INCHES})", + ) + parser.add_argument( + "--list-themes", action="store_true", + help="List all available themes", + ) + parser.add_argument( + "--display-city", "-dc", type=str, + help="Custom display name for city (for i18n support)", + ) + parser.add_argument( + "--display-country", "-dC", type=str, + help="Custom display name for country (for i18n support)", + ) + parser.add_argument( + "--font-family", type=str, + help='Google Fonts family name (e.g., "Noto Sans JP", "Open Sans"). ' + "If not specified, uses local Roboto fonts.", + ) + parser.add_argument( + "--format", "-f", default="png", choices=["png", "svg", "pdf"], + help="Output format for the poster (default: png)", + ) + parser.add_argument( + "--no-text", dest="no_text", action="store_true", + help="Generate poster without any text (city name, country, coordinates, attribution)", + ) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """ + CLI entry point. + + Args: + argv: Command-line arguments (defaults to sys.argv[1:]) + + Returns: + Exit code (0 for success, 1 for failure) + """ + parser = build_parser() + args = parser.parse_args(argv) + + # If no arguments provided, show examples + if len(sys.argv) == 1 and argv is None: + print_examples() + return 0 + + # List themes if requested + if args.list_themes: + list_themes() + return 0 + + # Validate required arguments + if not args.city or not args.country: + print("Error: --city and --country are required.\n") + print_examples() + return 1 + + # Enforce maximum dimensions + max_dim = constants.MAX_DIMENSION_INCHES + if args.width > max_dim: + print( + f"\u26a0 Width {args.width} exceeds the maximum allowed limit of " + f"{max_dim}. It's enforced as max limit {max_dim}." + ) + args.width = max_dim + if args.height > max_dim: + print( + f"\u26a0 Height {args.height} exceeds the maximum allowed limit of " + f"{max_dim}. It's enforced as max limit {max_dim}." + ) + args.height = max_dim + + available_themes = get_available_themes() + if not available_themes: + print("No themes found in 'themes/' directory.") + return 1 + + if args.all_themes: + themes_to_generate = available_themes + else: + if args.theme not in available_themes: + print(f"Error: Theme '{args.theme}' not found.") + print(f"Available themes: {', '.join(available_themes)}") + return 1 + themes_to_generate = [args.theme] + + print("=" * 50) + print("OpenCartograph") + print("=" * 50) + + # Load default fonts (Roboto) + default_fonts = load_fonts() + + # Load custom fonts if specified + custom_fonts = None + if args.font_family: + custom_fonts = load_fonts(args.font_family) + if not custom_fonts: + print(f"\u26a0 Failed to load '{args.font_family}', falling back to Roboto") + + final_fonts = custom_fonts or default_fonts + if final_fonts is None and not args.no_text: + print("\u2717 Error: No fonts available for text rendering. " + "Ensure Roboto fonts exist in the fonts/ directory, " + "or use --no-text to generate without text.") + return 1 + + # Get coordinates and generate poster + try: + if args.latitude and args.longitude: + lat = parse(args.latitude) + lon = parse(args.longitude) + coords = Coordinates(latitude=lat, longitude=lon) + print(f"\u2713 Coordinates: {lat}, {lon}") + else: + coords = get_coordinates(args.city, args.country) + + for theme_name in themes_to_generate: + theme = load_theme(theme_name) + output_file = generate_output_filename( + args.city, theme_name, args.format + ) + + # Resolve display names + display_city = args.display_city or args.city + display_country = args.display_country or args.country_label or args.country + + config = PosterConfig( + city=args.city, + country=args.country, + center=coords, + dist=args.distance, + width=args.width, + height=args.height, + theme=theme, + fonts=final_fonts, + output_file=output_file, + output_format=args.format, + display_city=display_city, + display_country=display_country, + no_text=args.no_text, + ) + + compose_poster(config, default_fonts=default_fonts) + + print("\n" + "=" * 50) + print("\u2713 Poster generation complete!") + print("=" * 50) + return 0 + + except Exception as e: + print(f"\n\u2717 Error: {e}") + traceback.print_exc() + return 1 diff --git a/opencartograph/constants.py b/opencartograph/constants.py new file mode 100644 index 000000000..092984126 --- /dev/null +++ b/opencartograph/constants.py @@ -0,0 +1,96 @@ +""" +Constants and default values used throughout the opencartograph package. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Project root is the parent of the opencartograph package directory +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# Directory paths (with env-var override for cache) +CACHE_DIR = Path(os.environ.get("CACHE_DIR", str(PROJECT_ROOT / "cache"))) +THEMES_DIR = PROJECT_ROOT / "themes" +FONTS_DIR = PROJECT_ROOT / "fonts" +FONTS_CACHE_DIR = FONTS_DIR / "cache" +POSTERS_DIR = PROJECT_ROOT / "posters" + +FILE_ENCODING = "utf-8" + +# Typography base sizes (reference dimension: 12 inches) +BASE_FONT_MAIN = 60 +BASE_FONT_SUB = 22 +BASE_FONT_COORDS = 14 +BASE_FONT_ATTR = 8 +FONT_REFERENCE_DIMENSION = 12.0 + +# City name length threshold for font size reduction +CITY_NAME_LENGTH_THRESHOLD = 10 +CITY_NAME_MIN_SCALE_FACTOR = 10 + +# Road widths by tier +ROAD_WIDTH_MOTORWAY = 1.2 +ROAD_WIDTH_PRIMARY = 1.0 +ROAD_WIDTH_SECONDARY = 0.8 +ROAD_WIDTH_TERTIARY = 0.6 +ROAD_WIDTH_DEFAULT = 0.4 + +# Gradient fade extent fractions (of axes height) +GRADIENT_BOTTOM_END = 0.25 +GRADIENT_TOP_START = 0.75 + +# Latin script detection +LATIN_SCRIPT_THRESHOLD = 0.8 +LATIN_UPPER_CODEPOINT = 0x250 + +# Maximum poster dimension (inches) +MAX_DIMENSION_INCHES = 20.0 + +# Poster DPI for raster formats +RASTER_DPI = 300 + +# Text vertical positions (fraction of axes height) +TEXT_Y_CITY = 0.14 +TEXT_Y_DIVIDER = 0.125 +TEXT_Y_COUNTRY = 0.10 +TEXT_Y_COORDS = 0.07 +TEXT_Y_ATTR = 0.02 +TEXT_X_ATTR = 0.98 + +# Divider line x-range +DIVIDER_X_START = 0.4 +DIVIDER_X_END = 0.6 + +# Rate limiting delays (seconds) +GEOCODING_DELAY = 1 +GRAPH_FETCH_DELAY = 0.5 +FEATURE_FETCH_DELAY = 0.3 + +# Default theme name +DEFAULT_THEME = "terracotta" + +# Default poster dimensions (inches) +DEFAULT_WIDTH = 12.0 +DEFAULT_HEIGHT = 16.0 + +# Default map distance (meters) +DEFAULT_DISTANCE = 18000 + +# City name letter spacing for Latin scripts +LATIN_LETTER_SPACING = " " + +# Coordinates display precision +COORD_DECIMAL_PLACES = 4 + +# Attribution text +ATTRIBUTION_TEXT = "\u00a9 OpenStreetMap contributors" +ATTRIBUTION_ALPHA = 0.5 +COORDS_ALPHA = 0.7 + +# Z-order values for rendering layers +ZORDER_WATER = 0.5 +ZORDER_PARKS = 0.8 +ZORDER_GRADIENT = 10 +ZORDER_TEXT = 11 diff --git a/opencartograph/fonts.py b/opencartograph/fonts.py new file mode 100644 index 000000000..16a1d4276 --- /dev/null +++ b/opencartograph/fonts.py @@ -0,0 +1,168 @@ +""" +Font loading and Google Fonts integration. + +Handles downloading fonts from Google Fonts API, local font loading, +and caching downloaded font files. +""" + +from __future__ import annotations + +import os +import re +from typing import Optional + +import requests + +from . import constants +from .models import FontSet + +# Weight names mapped to numeric values +WEIGHT_NAMES = {300: "light", 400: "regular", 700: "bold"} +DEFAULT_WEIGHTS = [300, 400, 700] + + +def download_google_font( + font_family: str, weights: list[int] | None = None +) -> Optional[dict[str, str]]: + """ + Download a font family from Google Fonts and cache it locally. + + Args: + font_family: Google Fonts family name (e.g., 'Noto Sans JP', 'Open Sans') + weights: List of font weights to download (300=light, 400=regular, 700=bold) + + Returns: + Dict with 'light', 'regular', 'bold' keys mapping to font file paths, + or None if download fails + """ + if weights is None: + weights = DEFAULT_WEIGHTS + + # Create fonts cache directory + constants.FONTS_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + # Normalize font family name for file paths + font_name_safe = font_family.replace(" ", "_").lower() + + font_files: dict[str, str] = {} + + # Google Fonts API endpoint - request all weights at once + weights_str = ";".join(map(str, weights)) + api_url = "https://fonts.googleapis.com/css2" + + params = {"family": f"{font_family}:wght@{weights_str}"} + headers = { + "User-Agent": "Mozilla/5.0" # Browser-like UA so Google Fonts returns woff2 + } + + try: + response = requests.get(api_url, params=params, headers=headers, timeout=10) + response.raise_for_status() + except requests.RequestException as e: + print(f"\u26a0 Could not reach Google Fonts API for '{font_family}': {e}") + return None + + css_content = response.text + + # Parse CSS to extract weight-specific URLs + weight_url_map: dict[int, str] = {} + font_face_blocks = re.split(r"@font-face\s*\{", css_content) + + for block in font_face_blocks[1:]: # Skip first empty split + weight_match = re.search(r"font-weight:\s*(\d+)", block) + if not weight_match: + continue + + weight = int(weight_match.group(1)) + + url_match = re.search(r"url\((https://[^)]+\.(woff2|ttf))\)", block) + if url_match: + weight_url_map[weight] = url_match.group(1) + + # Download each weight + for weight in weights: + weight_key = WEIGHT_NAMES.get(weight, "regular") + + weight_url = weight_url_map.get(weight) + + # If exact weight not found, try closest + if not weight_url and weight_url_map: + closest_weight = min( + weight_url_map.keys(), key=lambda x: abs(x - weight) + ) + weight_url = weight_url_map[closest_weight] + print( + f" Using weight {closest_weight} for {weight_key} " + f"(requested {weight} not available)" + ) + + if weight_url: + file_ext = "woff2" if weight_url.endswith(".woff2") else "ttf" + font_filename = f"{font_name_safe}_{weight_key}.{file_ext}" + font_path = constants.FONTS_CACHE_DIR / font_filename + + if not font_path.exists(): + print(f" Downloading {font_family} {weight_key} ({weight})...") + try: + font_response = requests.get(weight_url, timeout=10) + font_response.raise_for_status() + font_path.write_bytes(font_response.content) + except requests.RequestException as e: + print(f" \u26a0 Failed to download {weight_key}: {e}") + continue + else: + print(f" Using cached {font_family} {weight_key}") + + font_files[weight_key] = str(font_path) + + # Ensure we have at least regular weight + if "regular" not in font_files and font_files: + font_files["regular"] = list(font_files.values())[0] + print(f" Using {list(font_files.keys())[0]} weight as regular") + + # If we don't have all three weights, duplicate available ones + if "bold" not in font_files and "regular" in font_files: + font_files["bold"] = font_files["regular"] + print(" Using regular weight as bold") + if "light" not in font_files and "regular" in font_files: + font_files["light"] = font_files["regular"] + print(" Using regular weight as light") + + return font_files if font_files else None + + +def load_fonts(font_family: str | None = None) -> FontSet | None: + """ + Load fonts from local directory or download from Google Fonts. + + Args: + font_family: Google Fonts family name (e.g., 'Noto Sans JP', 'Open Sans'). + If None, uses local Roboto fonts. + + Returns: + FontSet with paths to font files, or None if all loading methods fail + """ + # If custom font family specified, try to download from Google Fonts + if font_family and font_family.lower() != "roboto": + print(f"Loading Google Font: {font_family}") + fonts = download_google_font(font_family) + if fonts: + print(f"\u2713 Font '{font_family}' loaded successfully") + return FontSet.from_dict(fonts) + print(f"\u26a0 Failed to load '{font_family}', falling back to local Roboto") + + # Default: Load local Roboto fonts + fonts_dir = constants.FONTS_DIR + font_paths = { + "bold": os.path.join(fonts_dir, "Roboto-Bold.ttf"), + "regular": os.path.join(fonts_dir, "Roboto-Regular.ttf"), + "light": os.path.join(fonts_dir, "Roboto-Light.ttf"), + } + + # Verify fonts exist + for _weight, path in font_paths.items(): + if not os.path.exists(path): + print(f"\u26a0 Font not found: {path}") + return None + + return FontSet.from_dict(font_paths) diff --git a/opencartograph/geocoding.py b/opencartograph/geocoding.py new file mode 100644 index 000000000..9ff2c97ec --- /dev/null +++ b/opencartograph/geocoding.py @@ -0,0 +1,87 @@ +""" +Geocoding via Nominatim (geopy). + +Converts city/country names to geographic coordinates with caching +and rate limiting. +""" + +from __future__ import annotations + +import asyncio +import time + +from geopy.geocoders import Nominatim + +from . import constants +from .cache import CacheError, cache_get, cache_set +from .models import Coordinates + + +def get_coordinates(city: str, country: str) -> Coordinates: + """ + Fetch coordinates for a given city and country using geopy. + Includes rate limiting to be respectful to the geocoding service. + + Args: + city: City name + country: Country name + + Returns: + Coordinates dataclass with latitude and longitude + + Raises: + ValueError: If geocoding fails or returns no result + """ + cache_key = f"coords_{city.lower()}_{country.lower()}" + try: + cached = cache_get(cache_key) + except CacheError as e: + print(f"Warning: {e} -- re-fetching coordinates") + cached = None + if cached: + print(f"\u2713 Using cached coordinates for {city}, {country}") + # Handle both old tuple format and new Coordinates format + if isinstance(cached, tuple): + return Coordinates(latitude=cached[0], longitude=cached[1]) + return cached + + print("Looking up coordinates...") + geolocator = Nominatim(user_agent="opencartograph", timeout=10) + + # Add a small delay to respect Nominatim's usage policy + time.sleep(constants.GEOCODING_DELAY) + + try: + location = geolocator.geocode(f"{city}, {country}") + except Exception as e: + raise ValueError(f"Geocoding failed for {city}, {country}: {e}") from e + + # If geocode returned a coroutine in some environments, run it to get the result. + if asyncio.iscoroutine(location): + try: + location = asyncio.run(location) + except RuntimeError as exc: + loop = asyncio.get_event_loop() + if loop.is_running(): + raise RuntimeError( + "Geocoder returned a coroutine while an event loop is already running. " + "Run this script in a synchronous environment." + ) from exc + location = loop.run_until_complete(location) + + if location: + addr = getattr(location, "address", None) + if addr: + print(f"\u2713 Found: {addr}") + else: + print("\u2713 Found location (address not available)") + print(f"\u2713 Coordinates: {location.latitude}, {location.longitude}") + coords = Coordinates(latitude=location.latitude, longitude=location.longitude) + try: + # Cache as tuple for backward compatibility with existing caches + cache_set(cache_key, (location.latitude, location.longitude)) + except CacheError as e: + print(e) + return coords + + raise ValueError(f"Could not find coordinates for {city}, {country}") diff --git a/opencartograph/models.py b/opencartograph/models.py new file mode 100644 index 000000000..8e33765da --- /dev/null +++ b/opencartograph/models.py @@ -0,0 +1,114 @@ +""" +Data models for the opencartograph package. + +All models are frozen dataclasses to prevent accidental mutation. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import constants + + +@dataclass(frozen=True) +class Coordinates: + """Geographic coordinates for the map center.""" + + latitude: float + longitude: float + + def format_display(self) -> str: + """Format as '12.9716° N / 77.5946° E' with correct hemisphere indicators.""" + lat_dir = "N" if self.latitude >= 0 else "S" + lon_dir = "E" if self.longitude >= 0 else "W" + prec = constants.COORD_DECIMAL_PLACES + return f"{abs(self.latitude):.{prec}f}\u00b0 {lat_dir} / {abs(self.longitude):.{prec}f}\u00b0 {lon_dir}" + + def as_tuple(self) -> tuple[float, float]: + """Return as (latitude, longitude) tuple for compatibility with osmnx/geopy.""" + return (self.latitude, self.longitude) + + +@dataclass(frozen=True) +class RoadColors: + """Color assignments for each road tier.""" + + motorway: str + primary: str + secondary: str + tertiary: str + residential: str + default: str + + +@dataclass(frozen=True) +class Theme: + """Complete visual theme loaded from a JSON file.""" + + name: str + description: str + bg: str + text: str + gradient_color: str + water: str + parks: str + roads: RoadColors + + @classmethod + def from_dict(cls, data: dict[str, str]) -> Theme: + """Construct from the flat JSON dict the theme files use.""" + return cls( + name=data["name"], + description=data.get("description", ""), + bg=data["bg"], + text=data["text"], + gradient_color=data["gradient_color"], + water=data["water"], + parks=data["parks"], + roads=RoadColors( + motorway=data["road_motorway"], + primary=data["road_primary"], + secondary=data["road_secondary"], + tertiary=data["road_tertiary"], + residential=data["road_residential"], + default=data["road_default"], + ), + ) + + +@dataclass(frozen=True) +class FontSet: + """Paths to bold/regular/light font files.""" + + bold: str + regular: str + light: str + + @classmethod + def from_dict(cls, data: dict[str, str]) -> FontSet: + """Construct from a dict with 'bold', 'regular', 'light' keys.""" + return cls( + bold=data["bold"], + regular=data["regular"], + light=data["light"], + ) + + +@dataclass(frozen=True) +class PosterConfig: + """All parameters needed to generate a single poster.""" + + city: str + country: str + center: Coordinates + dist: int + width: float + height: float + theme: Theme + fonts: FontSet | None + output_file: str + output_format: str + display_city: str + display_country: str + no_text: bool = False diff --git a/opencartograph/osm.py b/opencartograph/osm.py new file mode 100644 index 000000000..891c16031 --- /dev/null +++ b/opencartograph/osm.py @@ -0,0 +1,115 @@ +""" +OpenStreetMap data fetching via OSMnx. + +Handles downloading and caching of street networks and geographic features +(water, parks) from the Overpass API. +""" + +from __future__ import annotations + +import time +from typing import Any, cast + +import osmnx as ox +from osmnx._errors import InsufficientResponseError, ResponseStatusCodeError +from geopandas import GeoDataFrame +from networkx import MultiDiGraph + +import requests + +from . import constants +from .cache import CacheError, cache_get, cache_set +from .models import Coordinates + +# Enable OSMnx caching to avoid redundant network calls +ox.settings.use_cache = True +ox.settings.log_console = False + + +def fetch_graph(point: Coordinates, dist: int) -> MultiDiGraph | None: + """ + Fetch street network graph from OpenStreetMap. + + Uses caching to avoid redundant downloads. Fetches all network types + within the specified distance from the center point. + + Args: + point: Coordinates for center point + dist: Distance in meters from center point + + Returns: + MultiDiGraph of street network, or None if fetch fails + """ + lat, lon = point.latitude, point.longitude + cache_key = f"graph_{lat}_{lon}_{dist}" + cached = cache_get(cache_key) + if cached is not None: + print("\u2713 Using cached street network") + return cast(MultiDiGraph, cached) + + try: + g = ox.graph_from_point( + point.as_tuple(), + dist=dist, + dist_type="bbox", + network_type="all", + truncate_by_edge=True, + ) + time.sleep(constants.GRAPH_FETCH_DELAY) + try: + cache_set(cache_key, g) + except CacheError as e: + print(e) + return g + except ( + InsufficientResponseError, + ResponseStatusCodeError, + requests.RequestException, + ValueError, + ) as e: + print(f"Could not fetch street network: {e}") + return None + + +def fetch_features( + point: Coordinates, dist: int, tags: dict[str, Any], name: str +) -> GeoDataFrame | None: + """ + Fetch geographic features (water, parks, etc.) from OpenStreetMap. + + Uses caching to avoid redundant downloads. Fetches features matching + the specified OSM tags within distance from center point. + + Args: + point: Coordinates for center point + dist: Distance in meters from center point + tags: Dictionary of OSM tags to filter features + name: Name for this feature type (for caching and logging) + + Returns: + GeoDataFrame of features, or None if fetch fails + """ + lat, lon = point.latitude, point.longitude + tag_str = "_".join(tags.keys()) + cache_key = f"{name}_{lat}_{lon}_{dist}_{tag_str}" + cached = cache_get(cache_key) + if cached is not None: + print(f"\u2713 Using cached {name}") + return cast(GeoDataFrame, cached) + + try: + data = ox.features_from_point(point.as_tuple(), tags=tags, dist=dist) + time.sleep(constants.FEATURE_FETCH_DELAY) + try: + cache_set(cache_key, data) + except CacheError as e: + print(e) + return data + except ( + InsufficientResponseError, + ResponseStatusCodeError, + requests.RequestException, + ValueError, + ) as e: + print(f"Could not fetch {name}: {e}") + return None diff --git a/opencartograph/output.py b/opencartograph/output.py new file mode 100644 index 000000000..3c6838a9f --- /dev/null +++ b/opencartograph/output.py @@ -0,0 +1,71 @@ +""" +Output file handling for generated posters. + +Handles filename generation and saving matplotlib figures to disk. +""" + +from __future__ import annotations + +import os +from datetime import datetime + +import matplotlib.pyplot as plt +from matplotlib.figure import Figure + +from . import constants +from .models import PosterConfig + + +def generate_output_filename( + city: str, theme_name: str, output_format: str +) -> str: + """ + Generate unique output filename with city, theme, and datetime. + + Args: + city: City name + theme_name: Theme name + output_format: File format extension (png, svg, pdf) + + Returns: + Full path to output file in posters directory + """ + if not os.path.exists(constants.POSTERS_DIR): + os.makedirs(constants.POSTERS_DIR) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + city_slug = city.lower().replace(" ", "_") + ext = output_format.lower() + filename = f"{city_slug}_{theme_name}_{timestamp}.{ext}" + return os.path.join(constants.POSTERS_DIR, filename) + + +def save_poster(fig: Figure, config: PosterConfig) -> None: + """ + Save a matplotlib figure to disk in the configured format. + + Args: + fig: Matplotlib figure to save + config: Poster configuration with output path and format + """ + print(f"Saving to {config.output_file}...") + + fmt = config.output_format.lower() + save_kwargs: dict = dict( + facecolor=config.theme.bg, + bbox_inches="tight", + pad_inches=0.05, + ) + + # DPI matters mainly for raster formats + if fmt == "png": + save_kwargs["dpi"] = constants.RASTER_DPI + + try: + plt.savefig(config.output_file, format=fmt, **save_kwargs) + except OSError as e: + raise RuntimeError( + f"Failed to save poster to '{config.output_file}': {e}. " + f"Check that the directory exists and you have write permissions." + ) from e + print(f"\u2713 Done! Poster saved as {config.output_file}") diff --git a/opencartograph/rendering/__init__.py b/opencartograph/rendering/__init__.py new file mode 100644 index 000000000..5efb9394f --- /dev/null +++ b/opencartograph/rendering/__init__.py @@ -0,0 +1,7 @@ +""" +Rendering pipeline for OpenCartograph poster generation. +""" + +from .pipeline import compose_poster + +__all__ = ["compose_poster"] diff --git a/opencartograph/rendering/layers.py b/opencartograph/rendering/layers.py new file mode 100644 index 000000000..ebeb83e6f --- /dev/null +++ b/opencartograph/rendering/layers.py @@ -0,0 +1,243 @@ +""" +Individual render layers for the poster. + +Each layer function renders one visual element onto the matplotlib axes. +Layers are called in order by the pipeline orchestrator. +""" + +from __future__ import annotations + +from typing import Any + +import matplotlib.colors as mcolors +import numpy as np +import osmnx as ox +from geopandas import GeoDataFrame +from matplotlib.axes import Axes +from networkx import MultiDiGraph + +from .. import constants +from ..models import FontSet, PosterConfig +from ..road_styles import compute_edge_styles +from ..text import compute_city_font_size, format_city_display, make_font + + +def _render_polygon_layer( + gdf: GeoDataFrame | None, + ax: Axes, + crs: Any, + facecolor: str, + zorder: float, +) -> None: + """ + Filter to polygons, project, and plot a GeoDataFrame. + + Args: + gdf: GeoDataFrame of features (may be None or empty) + ax: Matplotlib axes to plot on + crs: Target CRS for projection + facecolor: Fill color for polygons + zorder: Z-order for layer stacking + """ + if gdf is None or gdf.empty: + return + polys = gdf[gdf.geometry.type.isin(["Polygon", "MultiPolygon"])] + if polys.empty: + return + try: + polys = ox.projection.project_gdf(polys) + except (ValueError, RuntimeError) as e: + print(f" Warning: GDF projection failed ({e}), using graph CRS fallback") + polys = polys.to_crs(crs) + polys.plot(ax=ax, facecolor=facecolor, edgecolor="none", zorder=zorder) + + +def render_water( + ax: Axes, + water: GeoDataFrame | None, + g_proj: MultiDiGraph, + config: PosterConfig, +) -> None: + """Render water polygons layer.""" + _render_polygon_layer( + water, ax, g_proj.graph["crs"], + config.theme.water, constants.ZORDER_WATER, + ) + + +def render_parks( + ax: Axes, + parks: GeoDataFrame | None, + g_proj: MultiDiGraph, + config: PosterConfig, +) -> None: + """Render parks/green space polygons layer.""" + _render_polygon_layer( + parks, ax, g_proj.graph["crs"], + config.theme.parks, constants.ZORDER_PARKS, + ) + + +def render_roads( + ax: Axes, + g_proj: MultiDiGraph, + config: PosterConfig, +) -> None: + """Render the road network with themed hierarchy colors and widths.""" + print("Applying road hierarchy colors...") + styles = compute_edge_styles(g_proj, config.theme.roads) + ox.plot_graph( + g_proj, + ax=ax, + bgcolor=config.theme.bg, + node_size=0, + edge_color=styles.colors, + edge_linewidth=styles.widths, + show=False, + close=False, + ) + + +def apply_viewport( + ax: Axes, + crop_xlim: tuple[float, float], + crop_ylim: tuple[float, float], +) -> None: + """Set aspect ratio and crop limits on the axes.""" + ax.set_aspect("equal", adjustable="box") + ax.set_xlim(crop_xlim) + ax.set_ylim(crop_ylim) + + +def create_gradient_fade( + ax: Axes, color: str, location: str = "bottom", zorder: float = 10 +) -> None: + """ + Create a fade effect at the top or bottom of the map. + + Args: + ax: Matplotlib axes + color: Background/gradient color (hex string) + location: 'bottom' or 'top' + zorder: Z-order for stacking + """ + vals = np.linspace(0, 1, 256).reshape(-1, 1) + gradient = np.hstack((vals, vals)) + + rgb = mcolors.to_rgb(color) + my_colors = np.zeros((256, 4)) + my_colors[:, 0] = rgb[0] + my_colors[:, 1] = rgb[1] + my_colors[:, 2] = rgb[2] + + if location == "bottom": + my_colors[:, 3] = np.linspace(1, 0, 256) + extent_y_start = 0.0 + extent_y_end = constants.GRADIENT_BOTTOM_END + else: + my_colors[:, 3] = np.linspace(0, 1, 256) + extent_y_start = constants.GRADIENT_TOP_START + extent_y_end = 1.0 + + custom_cmap = mcolors.ListedColormap(my_colors) + + xlim = ax.get_xlim() + ylim = ax.get_ylim() + y_range = ylim[1] - ylim[0] + + y_bottom = ylim[0] + y_range * extent_y_start + y_top = ylim[0] + y_range * extent_y_end + + ax.imshow( + gradient, + extent=(xlim[0], xlim[1], y_bottom, y_top), + aspect="auto", + cmap=custom_cmap, + zorder=zorder, + origin="lower", + ) + + +def render_gradients(ax: Axes, config: PosterConfig) -> None: + """Render top and bottom gradient fade overlays.""" + create_gradient_fade( + ax, config.theme.gradient_color, + location="bottom", zorder=constants.ZORDER_GRADIENT, + ) + create_gradient_fade( + ax, config.theme.gradient_color, + location="top", zorder=constants.ZORDER_GRADIENT, + ) + + +def render_typography( + ax: Axes, + config: PosterConfig, + default_fonts: FontSet | None, +) -> None: + """ + Render city name, country, coordinates, divider line, and attribution. + + Args: + ax: Matplotlib axes + config: Poster configuration + default_fonts: Default FontSet (Roboto), used for attribution fallback + """ + scale_factor = min(config.height, config.width) / constants.FONT_REFERENCE_DIMENSION + active_fonts = config.fonts + + # Create font properties for sub-elements + font_sub = make_font(active_fonts, "light", constants.BASE_FONT_SUB * scale_factor) + font_coords = make_font(active_fonts, "regular", constants.BASE_FONT_COORDS * scale_factor) + + # Format and size city name + spaced_city = format_city_display(config.display_city) + adjusted_font_size = compute_city_font_size( + config.display_city, constants.BASE_FONT_MAIN, scale_factor + ) + font_main = make_font(active_fonts, "bold", adjusted_font_size) + + text_color = config.theme.text + zorder = constants.ZORDER_TEXT + + # City name + ax.text( + 0.5, constants.TEXT_Y_CITY, spaced_city, + transform=ax.transAxes, color=text_color, + ha="center", fontproperties=font_main, zorder=zorder, + ) + + # Country name + ax.text( + 0.5, constants.TEXT_Y_COUNTRY, config.display_country.upper(), + transform=ax.transAxes, color=text_color, + ha="center", fontproperties=font_sub, zorder=zorder, + ) + + # Coordinates + coords_text = config.center.format_display() + ax.text( + 0.5, constants.TEXT_Y_COORDS, coords_text, + transform=ax.transAxes, color=text_color, + alpha=constants.COORDS_ALPHA, ha="center", + fontproperties=font_coords, zorder=zorder, + ) + + # Divider line + ax.plot( + [constants.DIVIDER_X_START, constants.DIVIDER_X_END], + [constants.TEXT_Y_DIVIDER, constants.TEXT_Y_DIVIDER], + transform=ax.transAxes, color=text_color, + linewidth=1 * scale_factor, zorder=zorder, + ) + + # Attribution (bottom right) — always uses default fonts if available + attr_fonts = default_fonts or active_fonts + font_attr = make_font(attr_fonts, "light", constants.BASE_FONT_ATTR) + ax.text( + constants.TEXT_X_ATTR, constants.TEXT_Y_ATTR, + constants.ATTRIBUTION_TEXT, + transform=ax.transAxes, color=text_color, + alpha=constants.ATTRIBUTION_ALPHA, ha="right", va="bottom", + fontproperties=font_attr, zorder=zorder, + ) diff --git a/opencartograph/rendering/pipeline.py b/opencartograph/rendering/pipeline.py new file mode 100644 index 000000000..14b1ee076 --- /dev/null +++ b/opencartograph/rendering/pipeline.py @@ -0,0 +1,143 @@ +""" +Poster rendering pipeline orchestrator. + +Coordinates data fetching, figure setup, layer rendering, and file output. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import matplotlib.pyplot as plt +import osmnx as ox +from geopandas import GeoDataFrame +from networkx import MultiDiGraph +from tqdm import tqdm + +from ..models import FontSet, PosterConfig +from ..osm import fetch_features, fetch_graph +from ..output import save_poster +from .layers import ( + apply_viewport, + render_gradients, + render_parks, + render_roads, + render_typography, + render_water, +) +from .viewport import get_crop_limits, setup_figure + + +@dataclass +class MapData: + """All fetched OSM data needed for rendering.""" + + graph: MultiDiGraph + water: GeoDataFrame | None + parks: GeoDataFrame | None + + +def fetch_map_data(config: PosterConfig, compensated_dist: int) -> MapData: + """ + Fetch all OSM data (street network, water, parks) with progress bar. + + Args: + config: Poster configuration + compensated_dist: Distance compensated for aspect ratio + + Returns: + MapData with all fetched data + + Raises: + RuntimeError: If street network data cannot be retrieved + """ + with tqdm( + total=3, + desc="Fetching map data", + unit="step", + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}", + ) as pbar: + # 1. Fetch Street Network + pbar.set_description("Downloading street network") + g = fetch_graph(config.center, compensated_dist) + if g is None: + raise RuntimeError("Failed to retrieve street network data.") + pbar.update(1) + + # 2. Fetch Water Features + pbar.set_description("Downloading water features") + water = fetch_features( + config.center, + compensated_dist, + tags={"natural": ["water", "bay", "strait"], "waterway": "riverbank"}, + name="water", + ) + pbar.update(1) + + # 3. Fetch Parks + pbar.set_description("Downloading parks/green spaces") + parks = fetch_features( + config.center, + compensated_dist, + tags={"leisure": "park", "landuse": ["grass", "cemetery"], "natural": "wood"}, + name="parks", + ) + pbar.update(1) + + print("\u2713 All data retrieved successfully!") + return MapData(graph=g, water=water, parks=parks) + + +def compose_poster( + config: PosterConfig, default_fonts: FontSet | None = None +) -> str: + """ + Orchestrate the full poster rendering pipeline. + + Args: + config: Complete poster configuration + default_fonts: Default FontSet (Roboto) for attribution text + + Returns: + Path to the saved output file + """ + print(f"\nGenerating map for {config.city}, {config.country}...") + + # Compute compensated distance for viewport crop + compensated_dist = int( + config.dist + * (max(config.height, config.width) / min(config.height, config.width)) + / 4 + ) + + # Fetch all map data + map_data = fetch_map_data(config, compensated_dist) + + # Setup figure + print("Rendering map...") + fig, ax = setup_figure(config) + + # Project graph to metric CRS + g_proj = ox.project_graph(map_data.graph) + + # Render layers in order + render_water(ax, map_data.water, g_proj, config) + render_parks(ax, map_data.parks, g_proj, config) + render_roads(ax, g_proj, config) + + # Apply viewport crop + crop_xlim, crop_ylim = get_crop_limits( + g_proj, config.center, fig, compensated_dist + ) + apply_viewport(ax, crop_xlim, crop_ylim) + + # Render overlays + render_gradients(ax, config) + if not config.no_text: + render_typography(ax, config, default_fonts) + + # Save and cleanup + save_poster(fig, config) + plt.close(fig) + + return config.output_file diff --git a/opencartograph/rendering/viewport.py b/opencartograph/rendering/viewport.py new file mode 100644 index 000000000..9691694b4 --- /dev/null +++ b/opencartograph/rendering/viewport.py @@ -0,0 +1,82 @@ +""" +Viewport and figure setup for poster rendering. + +Handles matplotlib figure creation and geographic crop limit calculations +to maintain the poster's aspect ratio. +""" + +from __future__ import annotations + +import osmnx as ox +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from networkx import MultiDiGraph +from shapely.geometry import Point + +from ..models import Coordinates, PosterConfig + + +def setup_figure(config: PosterConfig) -> tuple[Figure, Axes]: + """ + Create matplotlib figure with correct size and background color. + + Args: + config: Poster configuration + + Returns: + Tuple of (Figure, Axes) ready for rendering + """ + import matplotlib.pyplot as plt + + fig, ax = plt.subplots( + figsize=(config.width, config.height), facecolor=config.theme.bg + ) + ax.set_facecolor(config.theme.bg) + ax.set_position((0.0, 0.0, 1.0, 1.0)) + return fig, ax + + +def get_crop_limits( + g_proj: MultiDiGraph, + center: Coordinates, + fig: Figure, + dist: float, +) -> tuple[tuple[float, float], tuple[float, float]]: + """ + Crop inward to preserve aspect ratio while guaranteeing + full coverage of the requested radius. + + Args: + g_proj: Projected graph (metric CRS) + center: Map center coordinates + fig: Matplotlib figure (for aspect ratio) + dist: Compensated distance in meters + + Returns: + Tuple of ((x_min, x_max), (y_min, y_max)) crop limits + """ + # Project center point into graph CRS + center_pt = ox.projection.project_geometry( + Point(center.longitude, center.latitude), + crs="EPSG:4326", + to_crs=g_proj.graph["crs"], + )[0] + center_x, center_y = center_pt.x, center_pt.y + + fig_width, fig_height = fig.get_size_inches() + aspect = fig_width / fig_height + + # Start from the requested radius + half_x = dist + half_y = dist + + # Cut inward to match aspect + if aspect > 1: # landscape -> reduce height + half_y = half_x / aspect + else: # portrait -> reduce width + half_x = half_y * aspect + + return ( + (center_x - half_x, center_x + half_x), + (center_y - half_y, center_y + half_y), + ) diff --git a/opencartograph/road_styles.py b/opencartograph/road_styles.py new file mode 100644 index 000000000..9e1c34146 --- /dev/null +++ b/opencartograph/road_styles.py @@ -0,0 +1,91 @@ +""" +Road styling based on OpenStreetMap highway classification. + +Provides a unified single-pass computation of both edge colors and widths, +replacing the duplicate iteration in the original code. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from networkx import MultiDiGraph + +from . import constants +from .models import RoadColors + +# Highway type to tier mapping (single source of truth) +HIGHWAY_TIERS: dict[str, str] = {} +for _tier, _types in { + "motorway": ["motorway", "motorway_link"], + "primary": ["trunk", "trunk_link", "primary", "primary_link"], + "secondary": ["secondary", "secondary_link"], + "tertiary": ["tertiary", "tertiary_link"], + "residential": ["residential", "living_street", "unclassified"], +}.items(): + for _t in _types: + HIGHWAY_TIERS[_t] = _tier + +# Width by tier +TIER_WIDTHS: dict[str, float] = { + "motorway": constants.ROAD_WIDTH_MOTORWAY, + "primary": constants.ROAD_WIDTH_PRIMARY, + "secondary": constants.ROAD_WIDTH_SECONDARY, + "tertiary": constants.ROAD_WIDTH_TERTIARY, + "residential": constants.ROAD_WIDTH_DEFAULT, +} + + +@dataclass +class EdgeStyles: + """Colors and widths for every edge in the graph (parallel lists).""" + + colors: list[str] + widths: list[float] + + +def classify_highway(highway_value: str | list[str]) -> str: + """ + Normalize an OSM highway tag to a tier name. + + Args: + highway_value: Highway tag value (string or list of strings) + + Returns: + Tier name ('motorway', 'primary', 'secondary', 'tertiary', + 'residential', or 'default') + """ + if isinstance(highway_value, list): + highway_value = highway_value[0] if highway_value else "unclassified" + return HIGHWAY_TIERS.get(highway_value, "default") + + +def compute_edge_styles(graph: MultiDiGraph, road_colors: RoadColors) -> EdgeStyles: + """ + Single pass over all edges to compute both colors and widths. + + Args: + graph: Projected street network graph + road_colors: Color assignments from the theme + + Returns: + EdgeStyles with parallel colors and widths lists + """ + colors: list[str] = [] + widths: list[float] = [] + + color_map = { + "motorway": road_colors.motorway, + "primary": road_colors.primary, + "secondary": road_colors.secondary, + "tertiary": road_colors.tertiary, + "residential": road_colors.residential, + "default": road_colors.default, + } + + for _u, _v, data in graph.edges(data=True): + tier = classify_highway(data.get("highway", "unclassified")) + colors.append(color_map.get(tier, road_colors.default)) + widths.append(TIER_WIDTHS.get(tier, constants.ROAD_WIDTH_DEFAULT)) + + return EdgeStyles(colors=colors, widths=widths) diff --git a/opencartograph/text.py b/opencartograph/text.py new file mode 100644 index 000000000..0b4e75d4a --- /dev/null +++ b/opencartograph/text.py @@ -0,0 +1,115 @@ +""" +Text processing utilities for poster typography. + +Handles script detection, city name formatting, and font property creation. +""" + +from __future__ import annotations + +from matplotlib.font_manager import FontProperties + +from . import constants +from .models import FontSet + +# Mapping from FontSet attribute names to matplotlib weight strings +_WEIGHT_MAP = { + "bold": "bold", + "regular": "normal", + "light": "light", +} + + +def is_latin_script(text: str) -> bool: + """ + Check if text is primarily Latin script. + Used to determine if letter-spacing should be applied to city names. + + Args: + text: Text to analyze + + Returns: + True if text is primarily Latin script, False otherwise + """ + if not text: + return True + + latin_count = 0 + total_alpha = 0 + + for char in text: + if char.isalpha(): + total_alpha += 1 + # Latin Unicode ranges: Basic Latin through Latin Extended-B (U+0000-U+024F) + if ord(char) < constants.LATIN_UPPER_CODEPOINT: + latin_count += 1 + + # If no alphabetic characters, default to Latin (numbers, symbols, etc.) + if total_alpha == 0: + return True + + # Consider it Latin if >80% of alphabetic characters are Latin + return (latin_count / total_alpha) > constants.LATIN_SCRIPT_THRESHOLD + + +def format_city_display(city: str) -> str: + """ + Format city name for poster display. + + Latin scripts get uppercase with letter spacing (e.g., "P A R I S"). + Non-Latin scripts are preserved as-is. + + Args: + city: City display name + + Returns: + Formatted city name string + """ + if is_latin_script(city): + return constants.LATIN_LETTER_SPACING.join(list(city.upper())) + return city + + +def compute_city_font_size( + city: str, base_size: float, scale_factor: float +) -> float: + """ + Dynamically adjust font size based on city name length to prevent truncation. + + Args: + city: City display name (not the spaced version) + base_size: Base font size at reference dimension + scale_factor: Scale factor based on poster dimensions + + Returns: + Adjusted font size + """ + base_adjusted = base_size * scale_factor + char_count = len(city) + + if char_count > constants.CITY_NAME_LENGTH_THRESHOLD: + length_factor = constants.CITY_NAME_LENGTH_THRESHOLD / char_count + return max( + base_adjusted * length_factor, + constants.CITY_NAME_MIN_SCALE_FACTOR * scale_factor, + ) + return base_adjusted + + +def make_font( + fonts: FontSet | None, weight: str, size: float +) -> FontProperties: + """ + Create FontProperties with fallback to system monospace. + + Args: + fonts: FontSet with paths to font files, or None for system fallback + weight: Font weight name ('bold', 'regular', 'light') + size: Font size in points + + Returns: + Configured FontProperties instance + """ + if fonts is not None: + return FontProperties(fname=getattr(fonts, weight), size=size) + mpl_weight = _WEIGHT_MAP.get(weight, "normal") + return FontProperties(family="monospace", weight=mpl_weight, size=size) diff --git a/opencartograph/theme.py b/opencartograph/theme.py new file mode 100644 index 000000000..d264bc3c4 --- /dev/null +++ b/opencartograph/theme.py @@ -0,0 +1,128 @@ +""" +Theme loading and management. + +Themes are JSON files in the themes/ directory that define colors for +all visual elements of the poster. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from . import constants +from .models import Theme + + +# Embedded fallback theme used when theme file is missing +_FALLBACK_THEME_DATA: dict[str, str] = { + "name": "Terracotta", + "description": "Mediterranean warmth - burnt orange and clay tones on cream", + "bg": "#F5EDE4", + "text": "#8B4513", + "gradient_color": "#F5EDE4", + "water": "#A8C4C4", + "parks": "#E8E0D0", + "road_motorway": "#A0522D", + "road_primary": "#B8653A", + "road_secondary": "#C9846A", + "road_tertiary": "#D9A08A", + "road_residential": "#E5C4B0", + "road_default": "#D9A08A", +} + + +def get_available_themes(themes_dir: Path | None = None) -> list[str]: + """ + Scan the themes directory and return a list of available theme names. + + Args: + themes_dir: Override themes directory path + + Returns: + Sorted list of theme names (without .json extension) + """ + themes_dir = themes_dir or constants.THEMES_DIR + if not os.path.exists(themes_dir): + os.makedirs(themes_dir) + return [] + + themes = [] + for file in sorted(os.listdir(themes_dir)): + if file.endswith(".json"): + themes.append(file[:-5]) # Remove .json extension + return themes + + +def load_theme( + theme_name: str = "terracotta", themes_dir: Path | None = None +) -> Theme: + """ + Load theme from JSON file in themes directory. + + Args: + theme_name: Name of the theme (without .json extension) + themes_dir: Override themes directory path + + Returns: + Theme dataclass with all color values + """ + themes_dir = themes_dir or constants.THEMES_DIR + theme_file = os.path.join(themes_dir, f"{theme_name}.json") + + if not os.path.exists(theme_file): + available = get_available_themes(themes_dir) + raise FileNotFoundError( + f"Theme file '{theme_file}' not found. " + f"Available themes: {', '.join(available)}" + ) + + try: + with open(theme_file, "r", encoding=constants.FILE_ENCODING) as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Theme file '{theme_file}' contains invalid JSON: {e}") from e + + try: + theme = Theme.from_dict(data) + except KeyError as e: + raise ValueError(f"Theme file '{theme_file}' is missing required field: {e}") from e + + print(f"\u2713 Loaded theme: {data.get('name', theme_name)}") + if "description" in data: + print(f" {data['description']}") + return theme + + +def list_themes(themes_dir: Path | None = None) -> None: + """ + Print all available themes with descriptions. + + Args: + themes_dir: Override themes directory path + """ + themes_dir = themes_dir or constants.THEMES_DIR + available = get_available_themes(themes_dir) + if not available: + print("No themes found in 'themes/' directory.") + return + + print("\nAvailable Themes:") + print("-" * 60) + for theme_name in available: + theme_path = os.path.join(themes_dir, f"{theme_name}.json") + try: + with open(theme_path, "r", encoding=constants.FILE_ENCODING) as f: + theme_data = json.load(f) + display_name = theme_data.get("name", theme_name) + description = theme_data.get("description", "") + except (OSError, json.JSONDecodeError) as e: + print(f" Warning: Could not read theme file '{theme_name}.json': {e}") + display_name = theme_name + description = "(error reading theme file)" + print(f" {theme_name}") + print(f" {display_name}") + if description: + print(f" {description}") + print() diff --git a/pyproject.toml b/pyproject.toml index 666d9ed09..2642a847d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,15 @@ [project] -name = "maptoposter" -version = "0.2.0" -description = "Generate beautiful, minimalist map posters for any city in the world." +name = "opencartograph" +version = "0.4.0" +description = "An open-source cartographic rendering tool. Generate high-quality map visualizations for any location worldwide using OpenStreetMap data." readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} authors = [ - {name = "Ankur Gupta", email = "originalankur@github.com"} + {name = "Ankur Gupta", email = "originalankur@github.com"}, + {name = "Ryan Ramboer"}, ] -keywords = ["map", "poster", "osm", "openstreetmap", "art", "visualization"] +keywords = ["map", "poster", "osm", "openstreetmap", "cartography", "visualization"] dependencies = [ "certifi==2026.1.4", @@ -43,9 +44,23 @@ dependencies = [ "urllib3==2.6.3", ] +[project.optional-dependencies] + +[project.scripts] +opencartograph = "opencartograph.cli:main" + [build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -py-modules = ["create_map_poster"] +[tool.setuptools.packages.find] +include = ["opencartograph*"] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "flake8>=7.0", + "mypy>=1.0", + "types-requests>=2.31", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..067fa3839 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,79 @@ +"""Shared test fixtures for opencartograph tests.""" + +from __future__ import annotations + +import json + +import pytest + +from opencartograph.models import Coordinates, PosterConfig, Theme + + +SAMPLE_THEME_DATA = { + "name": "Test Theme", + "description": "A test theme", + "bg": "#FFFFFF", + "text": "#000000", + "gradient_color": "#FFFFFF", + "water": "#0000FF", + "parks": "#00FF00", + "road_motorway": "#FF0000", + "road_primary": "#CC0000", + "road_secondary": "#990000", + "road_tertiary": "#660000", + "road_residential": "#330000", + "road_default": "#111111", +} + + +@pytest.fixture +def sample_theme_data() -> dict[str, str]: + """Raw theme dict as loaded from JSON.""" + return SAMPLE_THEME_DATA.copy() + + +@pytest.fixture +def sample_theme() -> Theme: + """A Theme dataclass for testing.""" + return Theme.from_dict(SAMPLE_THEME_DATA) + + +@pytest.fixture +def sample_coords() -> Coordinates: + """Coordinates for Paris, France.""" + return Coordinates(latitude=48.8566, longitude=2.3522) + + +@pytest.fixture +def sample_config(sample_theme: Theme, sample_coords: Coordinates, tmp_path) -> PosterConfig: + """A minimal PosterConfig for testing.""" + return PosterConfig( + city="Paris", + country="France", + center=sample_coords, + dist=18000, + width=12.0, + height=16.0, + theme=sample_theme, + fonts=None, + output_file=str(tmp_path / "test_poster.png"), + output_format="png", + display_city="Paris", + display_country="France", + ) + + +@pytest.fixture +def themes_dir(tmp_path) -> str: + """A temporary themes directory with test theme files.""" + themes = tmp_path / "themes" + themes.mkdir() + + for name in ["alpha", "beta"]: + theme_file = themes / f"{name}.json" + data = SAMPLE_THEME_DATA.copy() + data["name"] = name.title() + data["description"] = f"The {name} theme" + theme_file.write_text(json.dumps(data)) + + return str(themes) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 000000000..807f4c493 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,36 @@ +"""Tests for opencartograph.cache.""" + +from __future__ import annotations + +from opencartograph.cache import cache_get, cache_set + + +class TestCache: + def test_round_trip(self, tmp_path): + cache_set("test_key", {"data": 42}, cache_dir=tmp_path) + result = cache_get("test_key", cache_dir=tmp_path) + assert result == {"data": 42} + + def test_missing_key_returns_none(self, tmp_path): + result = cache_get("nonexistent", cache_dir=tmp_path) + assert result is None + + def test_overwrite(self, tmp_path): + cache_set("key", "first", cache_dir=tmp_path) + cache_set("key", "second", cache_dir=tmp_path) + assert cache_get("key", cache_dir=tmp_path) == "second" + + def test_complex_objects(self, tmp_path): + data = [1, 2, {"nested": True}] + cache_set("complex", data, cache_dir=tmp_path) + assert cache_get("complex", cache_dir=tmp_path) == data + + def test_cache_dir_created_if_missing(self, tmp_path): + new_dir = tmp_path / "new_cache" + cache_set("key", "value", cache_dir=new_dir) + assert new_dir.exists() + assert cache_get("key", cache_dir=new_dir) == "value" + + def test_key_with_path_separators(self, tmp_path): + cache_set("a/b/c", "safe", cache_dir=tmp_path) + assert cache_get("a/b/c", cache_dir=tmp_path) == "safe" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..e96d1ee8f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,104 @@ +"""Tests for opencartograph.cli.""" + +from __future__ import annotations + +import pytest + +from opencartograph.cli import build_parser, main + + +class TestBuildParser: + def test_all_args_present(self): + parser = build_parser() + # Parse with all required args + args = parser.parse_args(["--city", "Paris", "--country", "France"]) + assert args.city == "Paris" + assert args.country == "France" + + def test_short_flags(self): + parser = build_parser() + args = parser.parse_args(["-c", "Tokyo", "-C", "Japan", "-t", "noir"]) + assert args.city == "Tokyo" + assert args.country == "Japan" + assert args.theme == "noir" + + def test_defaults(self): + parser = build_parser() + args = parser.parse_args(["-c", "X", "-C", "Y"]) + assert args.theme == "terracotta" + assert args.distance == 18000 + assert args.width == 12.0 + assert args.height == 16.0 + assert args.format == "png" + + def test_all_themes_flag(self): + parser = build_parser() + args = parser.parse_args(["-c", "X", "-C", "Y", "--all-themes"]) + assert args.all_themes is True + + def test_list_themes_flag(self): + parser = build_parser() + args = parser.parse_args(["--list-themes"]) + assert args.list_themes is True + + def test_display_city_and_country(self): + parser = build_parser() + args = parser.parse_args([ + "-c", "Tokyo", "-C", "Japan", + "-dc", "東京", "-dC", "日本", + ]) + assert args.display_city == "東京" + assert args.display_country == "日本" + + def test_font_family(self): + parser = build_parser() + args = parser.parse_args(["-c", "X", "-C", "Y", "--font-family", "Noto Sans JP"]) + assert args.font_family == "Noto Sans JP" + + def test_format_choices(self): + parser = build_parser() + for fmt in ["png", "svg", "pdf"]: + args = parser.parse_args(["-c", "X", "-C", "Y", "-f", fmt]) + assert args.format == fmt + + def test_invalid_format(self): + parser = build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["-c", "X", "-C", "Y", "-f", "bmp"]) + + def test_no_text_flag(self): + parser = build_parser() + args = parser.parse_args(["-c", "X", "-C", "Y", "--no-text"]) + assert args.no_text is True + + def test_no_text_default_false(self): + parser = build_parser() + args = parser.parse_args(["-c", "X", "-C", "Y"]) + assert args.no_text is False + + def test_latitude_longitude(self): + parser = build_parser() + args = parser.parse_args([ + "-c", "X", "-C", "Y", + "-lat", "40.7128", "-long", "-74.0060", + ]) + assert args.latitude == "40.7128" + assert args.longitude == "-74.0060" + + +class TestMain: + def test_list_themes_returns_0(self): + result = main(["--list-themes"]) + assert result == 0 + + def test_missing_city_returns_1(self): + result = main(["--country", "France"]) + assert result == 1 + + def test_missing_country_returns_1(self): + result = main(["--city", "Paris"]) + assert result == 1 + + def test_invalid_theme_returns_1(self): + result = main(["--city", "Paris", "--country", "France", "--theme", "nonexistent_theme_xyz"]) + assert result == 1 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 000000000..73658384f --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,99 @@ +"""Tests for opencartograph.models.""" + +from __future__ import annotations + +import pytest + +from opencartograph.models import Coordinates, FontSet, RoadColors, Theme + + +class TestCoordinates: + def test_north_east(self): + c = Coordinates(latitude=48.8566, longitude=2.3522) + assert c.format_display() == "48.8566\u00b0 N / 2.3522\u00b0 E" + + def test_south_west(self): + c = Coordinates(latitude=-33.8688, longitude=-151.2093) + assert c.format_display() == "33.8688\u00b0 S / 151.2093\u00b0 W" + + def test_south_east(self): + c = Coordinates(latitude=-6.2088, longitude=106.8456) + assert c.format_display() == "6.2088\u00b0 S / 106.8456\u00b0 E" + + def test_north_west(self): + c = Coordinates(latitude=40.7128, longitude=-74.0060) + assert c.format_display() == "40.7128\u00b0 N / 74.0060\u00b0 W" + + def test_zero_coords(self): + c = Coordinates(latitude=0.0, longitude=0.0) + assert "N" in c.format_display() + assert "E" in c.format_display() + + def test_as_tuple(self): + c = Coordinates(latitude=48.8566, longitude=2.3522) + assert c.as_tuple() == (48.8566, 2.3522) + + def test_frozen(self): + c = Coordinates(latitude=48.8566, longitude=2.3522) + with pytest.raises(AttributeError): + c.latitude = 0.0 # type: ignore[misc] + + +class TestRoadColors: + def test_creation(self): + rc = RoadColors( + motorway="#FF0000", primary="#CC0000", secondary="#990000", + tertiary="#660000", residential="#330000", default="#111111", + ) + assert rc.motorway == "#FF0000" + assert rc.default == "#111111" + + +class TestTheme: + def test_from_dict(self, sample_theme_data): + theme = Theme.from_dict(sample_theme_data) + assert theme.name == "Test Theme" + assert theme.bg == "#FFFFFF" + assert theme.roads.motorway == "#FF0000" + assert theme.roads.default == "#111111" + + def test_from_dict_missing_description(self, sample_theme_data): + del sample_theme_data["description"] + theme = Theme.from_dict(sample_theme_data) + assert theme.description == "" + + def test_from_dict_missing_key_raises(self, sample_theme_data): + del sample_theme_data["bg"] + with pytest.raises(KeyError): + Theme.from_dict(sample_theme_data) + + def test_frozen(self, sample_theme): + with pytest.raises(AttributeError): + sample_theme.bg = "#000000" # type: ignore[misc] + + +class TestFontSet: + def test_from_dict(self): + data = {"bold": "/path/bold.ttf", "regular": "/path/regular.ttf", "light": "/path/light.ttf"} + fs = FontSet.from_dict(data) + assert fs.bold == "/path/bold.ttf" + assert fs.regular == "/path/regular.ttf" + assert fs.light == "/path/light.ttf" + + def test_from_dict_missing_key(self): + with pytest.raises(KeyError): + FontSet.from_dict({"bold": "x", "regular": "y"}) + + +class TestPosterConfig: + def test_creation(self, sample_config): + assert sample_config.city == "Paris" + assert sample_config.center.latitude == 48.8566 + assert sample_config.theme.name == "Test Theme" + assert sample_config.output_format == "png" + assert sample_config.no_text is False + + def test_no_text_enabled(self, sample_config): + from dataclasses import replace + config = replace(sample_config, no_text=True) + assert config.no_text is True diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 000000000..c8022e28d --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,44 @@ +"""Tests for opencartograph.output.""" + +from __future__ import annotations + +import re +from unittest.mock import patch + +from opencartograph.output import generate_output_filename + + +class TestGenerateOutputFilename: + def test_format_png(self, tmp_path): + with patch("opencartograph.output.constants") as mock_constants: + mock_constants.POSTERS_DIR = str(tmp_path / "posters") + result = generate_output_filename("Paris", "noir", "png") + assert result.endswith(".png") + assert "paris" in result + assert "noir" in result + + def test_format_svg(self, tmp_path): + with patch("opencartograph.output.constants") as mock_constants: + mock_constants.POSTERS_DIR = str(tmp_path / "posters") + result = generate_output_filename("London", "ocean", "svg") + assert result.endswith(".svg") + + def test_city_with_spaces(self, tmp_path): + with patch("opencartograph.output.constants") as mock_constants: + mock_constants.POSTERS_DIR = str(tmp_path / "posters") + result = generate_output_filename("New York", "noir", "png") + assert "new_york" in result + + def test_creates_directory(self, tmp_path): + posters_dir = tmp_path / "new_posters" + with patch("opencartograph.output.constants") as mock_constants: + mock_constants.POSTERS_DIR = str(posters_dir) + generate_output_filename("Paris", "noir", "png") + assert posters_dir.exists() + + def test_timestamp_in_filename(self, tmp_path): + with patch("opencartograph.output.constants") as mock_constants: + mock_constants.POSTERS_DIR = str(tmp_path / "posters") + result = generate_output_filename("Paris", "noir", "png") + # Should contain YYYYMMDD_HHMMSS pattern + assert re.search(r"\d{8}_\d{6}", result) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 000000000..5c82966e4 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,66 @@ +"""Tests for opencartograph.rendering.pipeline.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import MagicMock, patch + +from opencartograph.rendering.pipeline import MapData, compose_poster + + +class TestComposePoster: + """Test that compose_poster conditionally renders typography.""" + + @patch("opencartograph.rendering.pipeline.plt") + @patch("opencartograph.rendering.pipeline.save_poster") + @patch("opencartograph.rendering.pipeline.render_typography") + @patch("opencartograph.rendering.pipeline.render_gradients") + @patch("opencartograph.rendering.pipeline.apply_viewport") + @patch("opencartograph.rendering.pipeline.get_crop_limits", return_value=((0, 1), (0, 1))) + @patch("opencartograph.rendering.pipeline.render_roads") + @patch("opencartograph.rendering.pipeline.render_parks") + @patch("opencartograph.rendering.pipeline.render_water") + @patch("opencartograph.rendering.pipeline.ox.project_graph") + @patch("opencartograph.rendering.pipeline.setup_figure") + @patch("opencartograph.rendering.pipeline.fetch_map_data") + def test_typography_rendered_when_no_text_false( + self, mock_fetch, mock_setup, mock_project, mock_water, mock_parks, + mock_roads, mock_crop, mock_viewport, mock_gradients, mock_typo, + mock_save, mock_plt, sample_config, + ): + mock_fetch.return_value = MapData( + graph=MagicMock(), water=None, parks=None, + ) + mock_setup.return_value = (MagicMock(), MagicMock()) + + config = replace(sample_config, no_text=False) + compose_poster(config) + + mock_typo.assert_called_once() + + @patch("opencartograph.rendering.pipeline.plt") + @patch("opencartograph.rendering.pipeline.save_poster") + @patch("opencartograph.rendering.pipeline.render_typography") + @patch("opencartograph.rendering.pipeline.render_gradients") + @patch("opencartograph.rendering.pipeline.apply_viewport") + @patch("opencartograph.rendering.pipeline.get_crop_limits", return_value=((0, 1), (0, 1))) + @patch("opencartograph.rendering.pipeline.render_roads") + @patch("opencartograph.rendering.pipeline.render_parks") + @patch("opencartograph.rendering.pipeline.render_water") + @patch("opencartograph.rendering.pipeline.ox.project_graph") + @patch("opencartograph.rendering.pipeline.setup_figure") + @patch("opencartograph.rendering.pipeline.fetch_map_data") + def test_typography_skipped_when_no_text_true( + self, mock_fetch, mock_setup, mock_project, mock_water, mock_parks, + mock_roads, mock_crop, mock_viewport, mock_gradients, mock_typo, + mock_save, mock_plt, sample_config, + ): + mock_fetch.return_value = MapData( + graph=MagicMock(), water=None, parks=None, + ) + mock_setup.return_value = (MagicMock(), MagicMock()) + + config = replace(sample_config, no_text=True) + compose_poster(config) + + mock_typo.assert_not_called() diff --git a/tests/test_road_styles.py b/tests/test_road_styles.py new file mode 100644 index 000000000..70f052ce3 --- /dev/null +++ b/tests/test_road_styles.py @@ -0,0 +1,86 @@ +"""Tests for opencartograph.road_styles.""" + +from __future__ import annotations + +import networkx as nx +import pytest + +from opencartograph.models import RoadColors +from opencartograph.road_styles import classify_highway, compute_edge_styles + + +class TestClassifyHighway: + def test_motorway(self): + assert classify_highway("motorway") == "motorway" + + def test_motorway_link(self): + assert classify_highway("motorway_link") == "motorway" + + def test_primary(self): + assert classify_highway("primary") == "primary" + + def test_trunk(self): + assert classify_highway("trunk") == "primary" + + def test_secondary(self): + assert classify_highway("secondary") == "secondary" + + def test_tertiary(self): + assert classify_highway("tertiary") == "tertiary" + + def test_residential(self): + assert classify_highway("residential") == "residential" + + def test_living_street(self): + assert classify_highway("living_street") == "residential" + + def test_unknown(self): + assert classify_highway("footway") == "default" + + def test_list_input(self): + assert classify_highway(["motorway", "trunk"]) == "motorway" + + def test_empty_list(self): + assert classify_highway([]) == "residential" # unclassified maps to residential + + +class TestComputeEdgeStyles: + def _make_graph(self, highway_types: list[str]) -> nx.MultiDiGraph: + g = nx.MultiDiGraph() + for i, hw in enumerate(highway_types): + g.add_edge(i, i + 1, highway=hw) + return g + + @pytest.fixture + def road_colors(self) -> RoadColors: + return RoadColors( + motorway="#M", primary="#P", secondary="#S", + tertiary="#T", residential="#R", default="#D", + ) + + def test_single_motorway(self, road_colors): + g = self._make_graph(["motorway"]) + styles = compute_edge_styles(g, road_colors) + assert styles.colors == ["#M"] + assert styles.widths == [1.2] + + def test_mixed_types(self, road_colors): + g = self._make_graph(["motorway", "residential", "secondary"]) + styles = compute_edge_styles(g, road_colors) + assert len(styles.colors) == 3 + assert len(styles.widths) == 3 + assert styles.colors[0] == "#M" + assert styles.colors[1] == "#R" + assert styles.colors[2] == "#S" + + def test_unknown_type_gets_default(self, road_colors): + g = self._make_graph(["footway"]) + styles = compute_edge_styles(g, road_colors) + assert styles.colors == ["#D"] + assert styles.widths == [0.4] + + def test_empty_graph(self, road_colors): + g = nx.MultiDiGraph() + styles = compute_edge_styles(g, road_colors) + assert styles.colors == [] + assert styles.widths == [] diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 000000000..5f7e96a65 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,103 @@ +"""Tests for opencartograph.text.""" + +from __future__ import annotations + +from opencartograph.text import ( + compute_city_font_size, + format_city_display, + is_latin_script, + make_font, +) +from opencartograph.models import FontSet + + +class TestIsLatinScript: + def test_english(self): + assert is_latin_script("Paris") is True + + def test_french_accents(self): + assert is_latin_script("Montréal") is True + + def test_german(self): + assert is_latin_script("München") is True + + def test_japanese(self): + assert is_latin_script("東京") is False + + def test_arabic(self): + assert is_latin_script("القاهرة") is False + + def test_korean(self): + assert is_latin_script("서울") is False + + def test_hindi(self): + assert is_latin_script("मुंबई") is False + + def test_empty_string(self): + assert is_latin_script("") is True + + def test_numbers_only(self): + assert is_latin_script("12345") is True + + def test_mixed_mostly_latin(self): + # More than 80% latin + assert is_latin_script("Paris 東") is True + + def test_mixed_mostly_nonlatin(self): + assert is_latin_script("東京 P") is False + + +class TestFormatCityDisplay: + def test_latin_city(self): + result = format_city_display("Paris") + assert result == "P A R I S" + + def test_latin_lowercase(self): + result = format_city_display("london") + assert result == "L O N D O N" + + def test_nonlatin_city(self): + result = format_city_display("東京") + assert result == "東京" + + def test_city_with_spaces(self): + result = format_city_display("New York") + assert "N E W" in result + + +class TestComputeCityFontSize: + def test_short_name(self): + size = compute_city_font_size("Paris", 60, 1.0) + assert size == 60.0 + + def test_long_name_reduced(self): + size = compute_city_font_size("San Francisco Bay", 60, 1.0) + assert size < 60.0 + + def test_scale_factor(self): + size_small = compute_city_font_size("Paris", 60, 0.5) + size_large = compute_city_font_size("Paris", 60, 2.0) + assert size_large > size_small + + def test_very_long_name_has_minimum(self): + size = compute_city_font_size("A" * 100, 60, 1.0) + assert size >= 10.0 # Minimum scale factor + + +class TestMakeFont: + def test_with_none_fonts_returns_monospace(self): + fp = make_font(None, "bold", 24.0) + # FontProperties with family="monospace" created + assert fp.get_size() == 24.0 + + def test_with_font_set(self, tmp_path): + # Create dummy font files + bold = tmp_path / "bold.ttf" + regular = tmp_path / "regular.ttf" + light = tmp_path / "light.ttf" + for f in [bold, regular, light]: + f.write_bytes(b"") # Empty file, enough for path test + + fs = FontSet(bold=str(bold), regular=str(regular), light=str(light)) + fp = make_font(fs, "bold", 16.0) + assert fp.get_size() == 16.0 diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 000000000..410e8904f --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,63 @@ +"""Tests for opencartograph.theme.""" + +from __future__ import annotations + +from pathlib import Path + +from opencartograph.theme import get_available_themes, list_themes, load_theme + + +class TestGetAvailableThemes: + def test_returns_sorted_list(self, themes_dir): + themes = get_available_themes(Path(themes_dir)) + assert themes == ["alpha", "beta"] + + def test_empty_dir(self, tmp_path): + empty = tmp_path / "empty_themes" + empty.mkdir() + themes = get_available_themes(empty) + assert themes == [] + + def test_nonexistent_dir_creates_it(self, tmp_path): + new_dir = tmp_path / "new_themes" + themes = get_available_themes(new_dir) + assert themes == [] + assert new_dir.exists() + + def test_ignores_non_json(self, tmp_path): + themes = tmp_path / "themes" + themes.mkdir() + (themes / "readme.txt").write_text("not a theme") + (themes / "test.json").write_text('{"name": "Test"}') + result = get_available_themes(themes) + assert result == ["test"] + + +class TestLoadTheme: + def test_loads_valid_theme(self, themes_dir): + theme = load_theme("alpha", Path(themes_dir)) + assert theme.name == "Alpha" + + def test_missing_theme_raises_error(self, tmp_path): + import pytest + with pytest.raises(FileNotFoundError, match="not found"): + load_theme("nonexistent", tmp_path) + + def test_theme_has_road_colors(self, themes_dir): + theme = load_theme("alpha", Path(themes_dir)) + assert theme.roads.motorway == "#FF0000" + + +class TestListThemes: + def test_prints_themes(self, themes_dir, capsys): + list_themes(Path(themes_dir)) + captured = capsys.readouterr() + assert "alpha" in captured.out + assert "beta" in captured.out + + def test_empty_dir(self, tmp_path, capsys): + empty = tmp_path / "empty" + empty.mkdir() + list_themes(empty) + captured = capsys.readouterr() + assert "No themes found" in captured.out diff --git a/uv.lock b/uv.lock index 8ff69c600..0b0e6ac29 100644 --- a/uv.lock +++ b/uv.lock @@ -179,6 +179,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -188,6 +292,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + [[package]] name = "fonttools" version = "4.61.1" @@ -284,6 +402,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[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 = "kiwisolver" version = "1.4.9" @@ -384,74 +511,76 @@ wheels = [ ] [[package]] -name = "maptoposter" -version = "0.2.0" -source = { editable = "." } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "geographiclib" }, - { name = "geopandas" }, - { name = "geopy" }, - { name = "idna" }, - { name = "kiwisolver" }, - { name = "lat-lon-parser" }, - { name = "matplotlib" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "osmnx" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "pyogrio" }, - { name = "pyparsing" }, - { name = "pyproj" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "requests" }, - { name = "scipy" }, - { name = "shapely" }, - { name = "six" }, - { name = "tqdm" }, - { name = "tzdata" }, - { name = "urllib3" }, -] - -[package.metadata] -requires-dist = [ - { name = "certifi", specifier = "==2026.1.4" }, - { name = "charset-normalizer", specifier = "==3.4.4" }, - { name = "contourpy", specifier = "==1.3.3" }, - { name = "cycler", specifier = "==0.12.1" }, - { name = "fonttools", specifier = "==4.61.1" }, - { name = "geographiclib", specifier = "==2.1" }, - { name = "geopandas", specifier = "==1.1.2" }, - { name = "geopy", specifier = "==2.4.1" }, - { name = "idna", specifier = "==3.11" }, - { name = "kiwisolver", specifier = "==1.4.9" }, - { name = "lat-lon-parser", specifier = "==1.3.1" }, - { name = "matplotlib", specifier = "==3.10.8" }, - { name = "networkx", specifier = "==3.6.1" }, - { name = "numpy", specifier = "==2.4.0" }, - { name = "osmnx", specifier = "==2.0.7" }, - { name = "packaging", specifier = "==25.0" }, - { name = "pandas", specifier = "==2.3.3" }, - { name = "pillow", specifier = "==12.1.0" }, - { name = "pyogrio", specifier = "==0.12.1" }, - { name = "pyparsing", specifier = "==3.3.1" }, - { name = "pyproj", specifier = "==3.7.2" }, - { name = "python-dateutil", specifier = "==2.9.0.post0" }, - { name = "pytz", specifier = "==2025.2" }, - { name = "requests", specifier = "==2.32.5" }, - { name = "scipy", specifier = "==1.16.3" }, - { name = "shapely", specifier = "==2.1.2" }, - { name = "six", specifier = "==1.17.0" }, - { name = "tqdm", specifier = "==4.67.1" }, - { name = "tzdata", specifier = "==2025.3" }, - { name = "urllib3", specifier = "==2.6.3" }, +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] [[package]] @@ -518,6 +647,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[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 = "networkx" version = "3.6.1" @@ -606,6 +792,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" }, ] +[[package]] +name = "opencartograph" +version = "0.4.0" +source = { editable = "." } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "geographiclib" }, + { name = "geopandas" }, + { name = "geopy" }, + { name = "idna" }, + { name = "kiwisolver" }, + { name = "lat-lon-parser" }, + { name = "matplotlib" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "osmnx" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyogrio" }, + { name = "pyparsing" }, + { name = "pyproj" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "scipy" }, + { name = "shapely" }, + { name = "six" }, + { name = "tqdm" }, + { name = "tzdata" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "flake8" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "certifi", specifier = "==2026.1.4" }, + { name = "charset-normalizer", specifier = "==3.4.4" }, + { name = "contourpy", specifier = "==1.3.3" }, + { name = "cycler", specifier = "==0.12.1" }, + { name = "fonttools", specifier = "==4.61.1" }, + { name = "geographiclib", specifier = "==2.1" }, + { name = "geopandas", specifier = "==1.1.2" }, + { name = "geopy", specifier = "==2.4.1" }, + { name = "idna", specifier = "==3.11" }, + { name = "kiwisolver", specifier = "==1.4.9" }, + { name = "lat-lon-parser", specifier = "==1.3.1" }, + { name = "matplotlib", specifier = "==3.10.8" }, + { name = "networkx", specifier = "==3.6.1" }, + { name = "numpy", specifier = "==2.4.0" }, + { name = "osmnx", specifier = "==2.0.7" }, + { name = "packaging", specifier = "==25.0" }, + { name = "pandas", specifier = "==2.3.3" }, + { name = "pillow", specifier = "==12.1.0" }, + { name = "pyogrio", specifier = "==0.12.1" }, + { name = "pyparsing", specifier = "==3.3.1" }, + { name = "pyproj", specifier = "==3.7.2" }, + { name = "python-dateutil", specifier = "==2.9.0.post0" }, + { name = "pytz", specifier = "==2025.2" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "scipy", specifier = "==1.16.3" }, + { name = "shapely", specifier = "==2.1.2" }, + { name = "six", specifier = "==1.17.0" }, + { name = "tqdm", specifier = "==4.67.1" }, + { name = "tzdata", specifier = "==2025.3" }, + { name = "urllib3", specifier = "==2.6.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "flake8", specifier = ">=7.0" }, + { name = "mypy", specifier = ">=1.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "types-requests", specifier = ">=2.31" }, +] + [[package]] name = "osmnx" version = "2.0.7" @@ -686,6 +961,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pillow" version = "12.1.0" @@ -773,6 +1057,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, ] +[[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 = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyogrio" version = "0.12.1" @@ -896,6 +1216,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, ] +[[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-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { 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" @@ -1071,6 +1421,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1083,6 +1487,27 @@ 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 = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + [[package]] name = "tzdata" version = "2025.3"