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"