diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css new file mode 100644 index 0000000..3b16ced --- /dev/null +++ b/docs/css/mkdocstrings.css @@ -0,0 +1,23 @@ +/* + Documentation layout styles for MkDocs Material. + Defines spacing, typography, and table formatting + to improve readability and consistency across docs. +*/ + +/* Add vertical spacing between documentation objects */ +.md-typeset .doc .doc-object { margin: 1.1rem 0; } + +/* Allow function/method signatures to wrap across lines */ +.md-typeset .doc .sig { white-space: normal; word-break: break-word; } + +/* Adjust table cell padding and align text to the top */ +.md-typeset .doc .table th, +.md-typeset .doc .table td { + padding: .5rem .75rem; + vertical-align: top; +} + +/* Apply alternating background color to table rows for readability */ +.md-typeset .doc .table tr:nth-child(odd) td { + background: var(--md-code-bg-color); +} diff --git a/docs/css/theme-variants.css b/docs/css/theme-variants.css new file mode 100644 index 0000000..b4707d4 --- /dev/null +++ b/docs/css/theme-variants.css @@ -0,0 +1,146 @@ +/* + Documentation theme styles for MkDocs Material. + Defines heading sizes, documentation object layout, + code signature blocks, and table formatting. + Includes multiple color/spacing schemes (brand, compact, comfy) + for different presentation preferences. +*/ + +/* Style level-1 headings */ +.md-typeset h1 { + font-size: 1.4rem; + color: var(--md-default-fg-color--light); + font-weight: 800; + letter-spacing: .01em; + margin-top: 1.2rem; + margin-bottom: .6rem; +} + +/* Style level-2 headings */ +.md-typeset h2 { + font-size: 1rem; + color: var(--md-default-fg-color--light); + font-weight: 700; + letter-spacing: .02em; + margin-top: .8rem; + margin-bottom: .4rem; +} + +/* Style level-3 headings */ +.md-typeset h3 { + font-size: 0.9rem; + color: var(--md-default-fg-color--light); + font-weight: 600; + letter-spacing: .01em; + margin-top: .6rem; + margin-bottom: .3rem; +} + +/* Card-like container for documentation objects */ +.md-typeset .doc .doc-object { + margin: 1rem 0 1.25rem; + padding: 1rem 1.25rem; + border: 1px solid var(--md-default-fg-color--lighter); + border-radius: .75rem; + background: var(--md-default-bg-color); + box-shadow: 0 1px 0 var(--md-default-fg-color--lighter); +} + +/* Heading inside a documentation object */ +.md-typeset .doc .doc-object > .doc-heading { + font-weight: 700; + margin-bottom: .5rem; +} + +/* Code signature block inside documentation */ +.md-typeset .doc .sig { + display: block; + white-space: pre-wrap; + word-break: break-word; + background: var(--md-code-bg-color); + padding: .5rem .75rem; + border-radius: .5rem; + line-height: 1.45; +} + +/* Section titles inside documentation */ +.md-typeset .doc .doc-section-title { + font-weight: 800; + text-transform: uppercase; + font-size: .78rem; + letter-spacing: .04em; + color: var(--md-default-fg-color--light); + margin-top: .9rem; + border-top: 1px solid var(--md-default-fg-color--lighter); + padding-top: .5rem; +} + +/* Table base styles */ +.md-typeset .doc .table table { + width: 100%; + border-collapse: collapse; +} +.md-typeset .doc .table thead th { + font-weight: 700; + border-bottom: 1px solid var(--md-default-fg-color--lighter); +} +.md-typeset .doc .table th, +.md-typeset .doc .table td { + padding: .45rem .6rem; + vertical-align: top; +} +/* Alternating row background */ +.md-typeset .doc .table tr:nth-child(odd) td { + background: var(--md-code-bg-color); +} + +/* ============================= */ +/* BRAND color scheme */ +/* ============================= */ +[data-md-color-scheme="brand"] { + --md-primary-fg-color: #6e56cf; + --md-accent-fg-color: #00c2a8; + --md-code-bg-color: rgba(110, 86, 207, .08); +} +[data-md-color-scheme="brand"] .md-typeset .doc .doc-object { + border-color: rgba(110, 86, 207, .35); + box-shadow: 0 1px 0 rgba(110, 86, 207, .25); +} + +/* ============================= */ +/* COMPACT color scheme */ +/* ============================= */ +[data-md-color-scheme="compact"] .md-typeset { + font-size: 0.94rem; + line-height: 1.55; +} +[data-md-color-scheme="compact"] .md-typeset .doc .doc-object { + margin: .75rem 0 1rem; + padding: .75rem .9rem; +} +[data-md-color-scheme="compact"] .md-typeset .doc .table th, +[data-md-color-scheme="compact"] .md-typeset .doc .table td { + padding: .35rem .5rem; +} +[data-md-color-scheme="compact"] .md-typeset .sig { + line-height: 1.35; +} + +/* ============================= */ +/* COMFY color scheme */ +/* ============================= */ +[data-md-color-scheme="comfy"] .md-typeset { + font-size: 1.05rem; + line-height: 1.7; +} +[data-md-color-scheme="comfy"] .md-typeset .doc .doc-object { + margin: 1.2rem 0 1.5rem; + padding: 1.1rem 1.35rem; +} +[data-md-color-scheme="comfy"] .md-typeset .doc .table th, +[data-md-color-scheme="comfy"] .md-typeset .doc .table td { + padding: .55rem .75rem; +} +[data-md-color-scheme="comfy"] .md-typeset .sig { + line-height: 1.55; +} diff --git a/docs/gen_ref_pages/config.py b/docs/gen_ref_pages/config.py new file mode 100644 index 0000000..eeff720 --- /dev/null +++ b/docs/gen_ref_pages/config.py @@ -0,0 +1,55 @@ +from pathlib import Path + +# Global configuration flags and constants +INCLUDE_PRIVATE = False # Whether to include private packages (names starting with "_") +SOURCE_DIR = Path("src") # Root source directory to search for packages + +# Mapping from source subdirectories > human-readable section titles +SECTION_TITLE_MAP: dict[str, str] = {} + +# Explicit ordering for sections when displaying documentation or indexes +SECTION_ORDER: dict[str, int] = {} + +# File extensions that should be recognized as linkable images +LINKABLE_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".ico", ".gif"} + + +def _is_pkg_dir(path: Path) -> bool: + """ + Check whether a given path points to a valid Python package directory. + + A valid package directory must: + 1. Be a directory. + 2. Contain an __init__.py file. + + :param path: Filesystem path to check. + :return: True if the path is a Python package directory, False otherwise. + """ + return path.is_dir() and (path / "__init__.py").exists() + + +def find_package_dir(include_private: bool = INCLUDE_PRIVATE) -> tuple[Path, str]: + """ + Locate the first valid Python package directory under SOURCE_DIR. + + - Private packages (names starting with "_") can be excluded unless explicitly allowed. + - If multiple candidates exist, the package with the lexicographically smallest name is chosen. + + :param include_private: Whether to allow private packages (default: global INCLUDE_PRIVATE). + :return: Tuple (package_path, package_name). + :raises SystemExit: If no valid package directory is found. + """ + # Gather all package directories under SOURCE_DIR + candidates = [ + package_path + for package_path in SOURCE_DIR.iterdir() + if _is_pkg_dir(package_path) and (include_private or not package_path.name.startswith("_")) + ] + + # No valid package found > stop execution with an error message + if not candidates: + raise SystemExit("No package found in src/. Make sure you have something like " "src/yourpkg/__init__.py") + + # Pick the lexicographically smallest directory (deterministic choice) + package_dir = min(candidates, key=lambda package_path: package_path.name) + return package_dir, package_dir.name diff --git a/docs/gen_ref_pages/context.py b/docs/gen_ref_pages/context.py new file mode 100644 index 0000000..3a841b1 --- /dev/null +++ b/docs/gen_ref_pages/context.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field + +# Record structure: +# - tuple: arbitrary metadata (e.g., file info) +# - tuple[str, ...]: path parts (e.g., ("pkg", "subpkg")) +# - str: identifier/name +# - bool: status flag +Record = tuple[tuple, tuple[str, ...], str, bool] + + +@dataclass +class Context: + """ + Holds the in-memory representation of a package hierarchy. (tree structure) + + Tracks: + - Created folder paths. + - Relationships between parent folders → child directories/modules. + - Records associated with discovered entities. + """ + + # Set of folder paths already created (each as a tuple of path parts). + created_folders: set[tuple[str, ...]] = field(default_factory=set) + + # Map: parent folder > set of child directories. + children_directories: dict[tuple[str, ...], set[tuple[str, ...]]] = field(default_factory=dict) + + # Map: parent folder > list of child modules. + children_modules: dict[tuple[str, ...], list[tuple[str, ...]]] = field(default_factory=dict) + + # Collected records for all discovered items (see Record type alias). + records: list[Record] = field(default_factory=list) + + def ensure_folder(self, parts: list[str]) -> None: + """ + Ensure that a folder path is registered in the context. + + If the folder path is not yet known: + - Add it to created_folders. + - Initialize its entry in children_directories and children_modules. + + :param parts: Path components for the folder (e.g., ["pkg", "subpkg"]). + :return: None + """ + key = tuple(parts) + + if key not in self.created_folders: + self.created_folders.add(key) + self.children_directories.setdefault(key, set()) + self.children_modules.setdefault(key, []) diff --git a/docs/gen_ref_pages/gen_ref_pages.py b/docs/gen_ref_pages/gen_ref_pages.py new file mode 100644 index 0000000..71a0da6 --- /dev/null +++ b/docs/gen_ref_pages/gen_ref_pages.py @@ -0,0 +1,150 @@ +""" +Automatic API Documentation Generator for MkDocs. + +This script integrates with the `mkdocs-gen-files` plugin to dynamically +generate API reference documentation from a Python package located in +the source directory (by default `src/`, but this can be changed in `config.py` +via the `SOURCE_DIR` setting). + +Workflow: +1. Detects the main package directory under `SOURCE_DIR` (logic in `config.py`). +2. Traverses the package structure (using `traverse.py`) to discover + modules, subpackages, and static files. +3. Generates Markdown pages for: + - Each Python module (using `::: module.path` blocks for mkdocstrings). + - Each package/directory (with backlinks, subdirectory lists, + module lists, and static file sections). +4. Stores navigation metadata in a `Context` object (`context.py`). +5. Builds navigation files: + - `reference/index.md` → top-level reference index page, + - `reference/SUMMARY.md` → literate navigation for MkDocs. + +Usage (short form): +- Run this script as part of the MkDocs build process (`mkdocs build` or `mkdocs serve`). +- The script will automatically: + - detect the target package, + - create a documentation tree in the `reference/` directory, + - prepare navigation for integration with MkDocs. + +Usage (with MkDocs): +1) Install required packages: + pip install mkdocs mkdocs-gen-files "mkdocstrings[python]" + +2) Ensure your project layout looks for example like this: + . + ├─ mkdocs.yml + ├─ docs/ + │ └─ gen_ref_pages/ + │ ├─ gen_ref_pages.py # this script + │ ├─ config.py + │ ├─ context.py + │ ├─ generate.py + │ ├─ helpers.py + │ └─ traverse.py + ├─ src/ # or another source dir set in config.SOURCE_DIR + │ └─ /... + +3) Configure `mkdocs.yml` with plugins and navigation, e.g.: + site_name: Your Documentation + plugins: + - search + - gen-files: + scripts: + - docs/gen_ref_pages/gen_ref_pages.py # path to this script + - mkdocstrings: + handlers: + python: + options: + show_source: true + docstring_style: google # or "sphinx"/"numpy", depending on your style + nav: + - Reference: reference/ # generated reference section + +4) Run: + - Live preview: mkdocs serve + - Build static site: mkdocs build + +Customization: +- Configuration is located in `config.py`: + - `SOURCE_DIR`: source directory where the package is located (default: `Path("src")`). + You can change this to any other directory (e.g. `Path("packages")`). + - `INCLUDE_PRIVATE`: whether to include private modules/packages (path parts starting with `_`). + - `SECTION_TITLE_MAP` and `SECTION_ORDER`: control naming and ordering of sections in navigation. + - `LINKABLE_IMAGE_EXTENSIONS`: which static files (in source directories) should be linked as clickable images. + +This script is intended to be run automatically as part of the MkDocs build process. +You can place it anywhere in your repository and reference its path under `gen-files.scripts`. +""" + +import sys +from pathlib import Path + +# Ensure the current directory is on sys.path so local imports work correctly +THIS_DIR = Path(__file__).resolve().parent +THIS_DIR_STR = str(THIS_DIR) +if THIS_DIR_STR not in sys.path: + sys.path.insert(0, THIS_DIR_STR) + +import mkdocs_gen_files # noqa E402 +from config import INCLUDE_PRIVATE, find_package_dir # noqa E402 +from context import Context # noqa E402 +from generate import generate_directory_pages, generate_module_pages # noqa E402 +from traverse import traverse_directories # noqa E402 + + +def _build_nav(package_name: str, ctx: Context) -> None: + """ + Build MkDocs navigation files from collected documentation records. + + - Creates `reference/index.md` with a title and introduction. + - Creates `reference/SUMMARY.md` containing a literate navigation structure. + + :param package_name: Name of the discovered package. + :param ctx: Context holding collected records from traversal. + :return: None + """ + nav = mkdocs_gen_files.Nav() + + # Sort collected records and populate the navigation + for _, display, doc_rel_path, _ in sorted(ctx.records, key=lambda record: record[0]): + nav[display] = doc_rel_path + + # Generate top-level index page + with mkdocs_gen_files.open("reference/index.md", "w") as file_handle: + file_handle.write(f"# Reference – `{package_name}`\n\n") + file_handle.write("This section contains API documentation automatically " "generated from code.\n\n") + + # Generate SUMMARY.md for MkDocs navigation + with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) + + +def main() -> None: + """ + Entry point for documentation generation. + + - Finds the main package under `src/`. + - Traverses directories to collect modules/folders. + - Generates module and directory pages. + - Builds MkDocs navigation files. + + :return: None + """ + package_dir, package_name = find_package_dir(INCLUDE_PRIVATE) + + # Initialize a context object to hold traversal state and records + ctx = Context() + + # Walk through directories and collect module/folder information + traverse_directories(package_dir, package_name, INCLUDE_PRIVATE, ctx) + + # Generate documentation pages for modules and directories + generate_module_pages(package_dir, ctx) + generate_directory_pages(ctx) + + # Build navigation index files + _build_nav(package_name, ctx) + + +# Run the main process when the script is executed +main() diff --git a/docs/gen_ref_pages/generate.py b/docs/gen_ref_pages/generate.py new file mode 100644 index 0000000..c25ec9f --- /dev/null +++ b/docs/gen_ref_pages/generate.py @@ -0,0 +1,267 @@ +from pathlib import Path +from typing import TextIO + +import mkdocs_gen_files +from config import LINKABLE_IMAGE_EXTENSIONS, SOURCE_DIR +from context import Context +from helpers import display_parts_for, is_private, sort_key_for + + +def _iter_public_python_files(package_dir: Path) -> list[Path]: + """ + Recursively collect all public Python files within a package directory. + + - Excludes __init__.py files. + - Excludes private files (names starting with "_"). + + :param package_dir: Base package directory. + :return: Sorted list of Python file paths. + """ + files: list[Path] = [] + for python_file in package_dir.rglob("*.py"): + if python_file.name == "__init__.py": + continue + if is_private(python_file): + continue + files.append(python_file) + return sorted(files) + + +def _parts_from_source(python_file: Path) -> tuple[str, ...]: + """ + Convert a Python file path under SOURCE_DIR into module parts. + + Example: + src/pkg/module.py > ("pkg", "module") + + :param python_file: Path to a Python file under SOURCE_DIR. + :return: Tuple of path components without an extension. + """ + return tuple(python_file.relative_to(SOURCE_DIR).with_suffix("").parts) + + +def _write_backlink_if_needed(fh: TextIO, parts: list[str]) -> None: + """ + Write a backlink to the parent index if the page is not top-level. + + :param fh: File handle to write to. + :param parts: Current parts representing this page. + :return: None + """ + if len(parts) > 1: + parent_label = display_parts_for(parts[:-1])[-1] + fh.write(f"[⬅ Back to {parent_label}](../index.md)\n\n") + + +def _record_page(ctx: Context, display_parts: list[str], doc_path: Path, is_directory: bool) -> None: + """ + Add a record of a generated page to the context. + + :param ctx: Shared Context object. + :param display_parts: Human-readable parts for display. + :param doc_path: Path to the generated documentation file. + :param is_directory: Whether the record refers to a directory index. + :return: None + """ + display_tuple = tuple(display_parts) + doc_rel_path = doc_path.relative_to("reference").as_posix() + ctx.records.append((sort_key_for(display_parts), display_tuple, doc_rel_path, is_directory)) + + +def _display_sort_key(parts_like: tuple[str, ...]) -> tuple: + """ + Compute a sort key for display purposes. + + :param parts_like: Tuple of path parts. + :return: Sort key tuple. + """ + return sort_key_for(display_parts_for(list(parts_like))) + + +def _write_module_page(doc_path: Path, module_path: str, parent_parts: tuple[str, ...], source_file: Path) -> None: + """ + Generate a documentation page for a single module. + + :param doc_path: Target documentation path (index.md). + :param module_path: Dotted module path (e.g., "pkg.module"). + :param parent_parts: Path parts for the parent package. + :param source_file: Path to the source Python file. + :return: None + """ + mkdocs_gen_files.set_edit_path(doc_path, source_file) + with mkdocs_gen_files.open(doc_path, "w") as file_handle: + if parent_parts: + _write_backlink_if_needed(file_handle, list(parent_parts) + ["_placeholder_"]) + file_handle.write(f"::: {module_path}\n") + + +def _collect_static_files(source_folder_fs: Path) -> list[Path]: + """ + Collect non-Python static files in a source folder. + + - Skips private files (names starting with "_"). + - Includes only non-.py files. + + :param source_folder_fs: Filesystem path to the source folder. + :return: List of static file paths. + """ + static_files: list[Path] = [] + if source_folder_fs.exists(): + for source_file in sorted(source_folder_fs.iterdir()): + if source_file.name.startswith("_"): + continue + if source_file.is_file() and source_file.suffix != ".py": + static_files.append(source_file) + return static_files + + +def _emit_static_files_list( + parts: list[str], + static_files: list[Path], + file_handle: TextIO, +) -> None: + """ + Emit a section listing static files for a given folder. + + - Copies static files into `reference/.../_files/`. + - Links images if the extension is in LINKABLE_IMAGE_EXTENSIONS. + + :param parts: Path parts of the folder. + :param static_files: List of static file paths. + :param file_handle: File handle to write documentation into. + :return: None + """ + if not static_files: + return + + file_handle.write("## 🗃️ Static Files\n\n") + + for file_path in static_files: + destination = Path("reference", *parts, "_files", file_path.name) + + # Copy file content into the documentation tree + with mkdocs_gen_files.open(destination, "wb") as out_file: + out_file.write(file_path.read_bytes()) + + # Build relative link + relative_link = f"_files/{file_path.name}" + ext = file_path.suffix.lower() + + # Display images as clickable links + if ext in LINKABLE_IMAGE_EXTENSIONS: + file_handle.write(f"- [{file_path.name}]({relative_link})\n") + else: + file_handle.write(f"- {file_path.name}\n") + + file_handle.write("\n") + + +def _write_directory_page( + ctx: Context, + parts: list[str], + subdirectories: list[tuple[str, ...]], + modules: list[tuple[str, ...]], + static_files: list[Path], +) -> Path: + """ + Generate a documentation index page for a directory. + + Includes: + - Backlink to parent if applicable. + - Subdirectory links. + - Module links. + - Static file listings. + + :param ctx: Context to update with record. + :param parts: Path parts of the directory. + :param subdirectories: List of subdirectory paths. + :param modules: List of module paths. + :param static_files: List of static file paths in this directory. + :return: Path to generated index.md file. + """ + doc_path = Path("reference", *parts, "index.md") + + mkdocs_gen_files.set_edit_path(doc_path, SOURCE_DIR.joinpath(*parts, "__init__.py")) + + with mkdocs_gen_files.open(doc_path, "w") as file_handle: + _write_backlink_if_needed(file_handle, parts) + + file_handle.write(f"# `{'.'.join(parts)}`\n\n") + + if subdirectories: + file_handle.write("## 📁 Subdirectories\n\n") + for child in subdirectories: + label = display_parts_for(list(child))[-1] + file_handle.write(f"- [{label}]({child[-1]}/index.md)\n") + file_handle.write("\n") + + if modules: + file_handle.write("## 📄 Modules\n\n") + for child in modules: + label = display_parts_for(list(child))[-1] + file_handle.write(f"- [{label}]({child[-1]}/index.md)\n") + file_handle.write("\n") + + _emit_static_files_list(parts, static_files, file_handle) + + # Fallback if directory is empty + if not subdirectories and not modules and not static_files: + file_handle.write("_This section has no subdirectories, modules, or static files yet._\n") + + _record_page(ctx, display_parts_for(parts), doc_path, is_directory=True) + return doc_path + + +def generate_module_pages(package_dir: Path, ctx: Context) -> None: + """ + Generate documentation pages for all public modules under a package. + + :param package_dir: Root package directory. + :param ctx: Context object to update. + :return: None + """ + for python_file in _iter_public_python_files(package_dir): + parts = _parts_from_source(python_file) + parent_parts = parts[:-1] + + # Ensure parent folder exists in context + ctx.ensure_folder(list(parent_parts)) + + # Add this module under its parent + ctx.children_modules.setdefault(parent_parts, []).append(parts) + + module_path = ".".join(parts) + doc_path = Path("reference", *parts, "index.md") + + # Write module page and record it + _write_module_page(doc_path, module_path, parent_parts, python_file) + _record_page(ctx, display_parts_for(list(parts)), doc_path, is_directory=False) + + +def generate_directory_pages(ctx: Context) -> None: + """ + Generate documentation index pages for all discovered directories. + + :param ctx: Context object holding traversal state. + :return: None + """ + for key in sorted(ctx.created_folders): + parts = list(key) + parts_tuple = tuple(parts) + + source_folder_fs = SOURCE_DIR.joinpath(*parts) + source_folder_fs = SOURCE_DIR.joinpath(*parts) + static_files = _collect_static_files(source_folder_fs) + + # Gather subdirectories and modules for this folder + subdirectories = sorted( + ctx.children_directories.get(parts_tuple, set()), + key=_display_sort_key, + ) + modules = sorted( + ctx.children_modules.get(parts_tuple, []), + key=_display_sort_key, + ) + + # Write directory index page + _write_directory_page(ctx, parts, subdirectories, modules, static_files) diff --git a/docs/gen_ref_pages/helpers.py b/docs/gen_ref_pages/helpers.py new file mode 100644 index 0000000..7710695 --- /dev/null +++ b/docs/gen_ref_pages/helpers.py @@ -0,0 +1,83 @@ +from collections.abc import Iterable +from pathlib import Path + +from config import INCLUDE_PRIVATE, SECTION_ORDER, SECTION_TITLE_MAP + + +def is_private(path: Path) -> bool: + """ + Determine whether a given path should be considered private. + + Rules: + - If INCLUDE_PRIVATE is True > nothing is private. + - Otherwise, any path component starting with "_" marks it as private. + + :param path: Filesystem path. + :return: True if the path is private, False otherwise. + """ + return False if INCLUDE_PRIVATE else any(package_path.startswith("_") for package_path in path.parts) + + +def prettify(label: str) -> str: + """ + Convert an identifier string into a user-friendly label. + + - Underscores are replaced with spaces. + - Each word is capitalized. + + Example: + "my_module" > "My Module" + + :param label: Original string. + :return: Prettified version. + """ + return label.replace("_", " ").title() + + +def display_parts_for(parts: list[str]) -> list[str]: + """ + Build display-friendly parts for navigation from raw path parts. + + - Second element may be replaced by a mapped section title (SECTION_TITLE_MAP). + - Remaining elements are prettified. + + Example: + ["mypkg", "ui", "main_window"] + > ["mypkg", "UI", "Main Window"] + + :param parts: Raw path components. + :return: List of human-readable display parts. + """ + display = list(parts) + + # Replace known section keys (e.g., "ui" > "UI") + if len(display) >= 2 and display[1] in SECTION_TITLE_MAP: + display[1] = SECTION_TITLE_MAP[display[1]] + + # Prettify remaining elements + for i in range(1, len(display)): + display[i] = prettify(display[i]) + + return display + + +def sort_key_for(display_parts: Iterable[str]) -> tuple: + """ + Build a sort key for consistent ordering of navigation items. + + Criteria: + 1. Section order (from SECTION_ORDER, default 999). + 2. Path length (shorter first). + 3. Alphabetical (case-insensitive). + + :param display_parts: Human-readable path parts. + :return: Tuple usable as the sort key. + """ + path_parts = list(display_parts) + section = path_parts[1] if len(path_parts) >= 2 else "" + + return ( + SECTION_ORDER.get(section, 999), + len(path_parts), + tuple(part.lower() for part in path_parts), + ) diff --git a/docs/gen_ref_pages/traverse.py b/docs/gen_ref_pages/traverse.py new file mode 100644 index 0000000..5017462 --- /dev/null +++ b/docs/gen_ref_pages/traverse.py @@ -0,0 +1,124 @@ +import os +from collections.abc import Iterable +from pathlib import Path + +from context import Context +from helpers import is_private + + +def _walk_dirs(package_dir: Path, include_private: bool) -> Iterable[tuple[Path, list[str]]]: + """ + Walk the filesystem tree starting at package_dir and yield + (relative_dir, subdirs) pairs. + + Rules: + - Private subdirectories (names starting with "_") are removed in-place + unless include_private=True. + - Entire directories marked private are skipped completely. + + :param package_dir: Path to the root package (e.g., src/mypkg). + :param include_private: Whether to include private directories. + :return: Iterator of (relative_dir, subdirs). + + Example: + "src/mypkg" > [(Path("mypkg"), ["subdir1", "subdir2"])] + """ + source_dir = package_dir.parent + + # Walk filesystem tree starting at package_dir + for current_dirpath, subdirs, _ in os.walk(package_dir): + relative_dir = Path(current_dirpath).relative_to(source_dir) + + # Remove private subdirectories in-place if not including them + if not include_private: + subdirs[:] = [dirname for dirname in subdirs if not dirname.startswith("_")] + + # Skip the current directory if it's marked private + if is_private(relative_dir): + continue + + yield relative_dir, subdirs + + +def _parts_for(relative_dir: Path, package_name: str) -> list[str]: + """ + Convert a relative path into parts; fallback to package_name for the root. + + :param relative_dir: Path relative to the source directory. + :param package_name: Top-level package name. + :return: List of path components. + + Examples: + "mypkg/subdir" > ["mypkg", "subdir"] + "." (root) > ["mypkg"] + """ + return list(relative_dir.parts) or [package_name] + + +def _register_folder(ctx: Context, folder_parts: list[str]) -> tuple[str, ...]: + """ + Ensure the given folder exists in Context and return it as a tuple key. + + :param ctx: Shared Context object. + :param folder_parts: List of folder path components. + :return: Tuple representation of the folder path. + + Example: + ["mypkg", "subdir"] > ("mypkg", "subdir") + """ + ctx.ensure_folder(folder_parts) + return tuple(folder_parts) + + +def _register_children(ctx: Context, parent_parts: list[str], child_names: Iterable[str]) -> None: + """ + Register each child subdirectory of a given parent folder in Context. + + :param ctx: Shared Context object. + :param parent_parts: Parent folder path components. + :param child_names: Iterable of subdirectory names. + :return: None + + Example: + ["mypkg"], ["a", "b"] > {("mypkg", "a"), ("mypkg", "b")} + """ + parent_t = tuple(parent_parts) + # Register each child subdirectory + for dirname in sorted(child_names): + child_parts = parent_parts + [dirname] + ctx.ensure_folder(child_parts) + ctx.children_directories[parent_t].add(tuple(child_parts)) + + +def traverse_directories( + package_dir: Path, + package_name: str, + include_private: bool, + ctx: Context, +) -> None: + """ + Walk through the package directory tree and populate the Context + with discovered folders and their relationships. + + Rules: + - Private directories (names starting with "_") are skipped unless + include_private=True. + - Each valid folder is registered in ctx.created_folders. + - Parent > child directory relationships are stored in ctx.children_directories. + + :param package_dir: Path to the root package (e.g., src/mypkg). + :param package_name: Top-level package name. + :param include_private: Whether to include private directories. + :param ctx: Shared Context object to update. + :return: None + + Example: + "src/mypkg" > {("mypkg",)} + """ + for relative_dir, subdirs in _walk_dirs(package_dir, include_private=include_private): + # Convert the path into parts; fallback to package_name for root + folder_parts = _parts_for(relative_dir, package_name) + _register_folder(ctx, folder_parts) + + # Register each child subdirectory + _register_children(ctx, folder_parts, subdirs) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ef19199 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,168 @@ +# RailNetworkGraph + +A desktop application (built with **CustomTkinter**) that **finds the fastest railway connections** based on **GTFS timetable data**. +It calculates **route distance**, **ticket price**, and displays the **path on an interactive map**. + +The input data consists of **GTFS files** (`routes.txt`, `stops.txt`, `trips.txt`, `stop_times.txt`), +from which the application builds **time-expanded** and **station-level graphs**. + +> **License:** `CC0-1.0` +> **Requirements:** `Python 3.12–3.14`, `CustomTkinter`, `Pandas`, `Matplotlib`, `GeoPandas`, `NetworkX` + +------------------------------------------------------------------------ + +## Features + +- **Fastest route search** + - Uses **Dijkstra’s algorithm** on a **time-expanded graph** built from GTFS data. + - Considers **departure and transfer times** between trains. +- **Distance and price calculation** + - Computes **railway segment lengths** (not straight-line). + - Ticket price calculated based on **distance-based price table** and **discounts**. +- **Interactive visualization** + - Map (Matplotlib) with **stations**, **connections**, and **region borders**. + - Route highlighted in red. +- **Station info popups** + - Click on a station to view **nearest departures**. +- **CustomTkinter GUI** + - Multiple panels: input, results, interactive map. + +------------------------------------------------------------------------ + +## Requirements & Dependencies + +- **Python:** 3.12–3.14 +- **System:** Windows (PyInstaller build), other systems supported via source install. +- **Runtime libraries:** `customtkinter`, `pandas`, `matplotlib`, `geopandas`, `networkx` +- **Dev (optional):** `ruff`, `black`, `mypy`, `pyinstaller`, etc. (see `requirements-dev.txt`) + +------------------------------------------------------------------------ + +## Installation + +### Poetry + +**Users** (runtime dependencies only): + +``` bash +poetry install --without dev +poetry run rail_network_graph +``` + +**Developers** (runtime + dev dependencies): + +``` bash +poetry install +poetry run rail_network_graph +``` + +### Pip (from local repo) + +**Users** (runtime dependencies only): + +``` bash +pip install -r requirements.txt +pip install -e . +rail_network_graph +# or: +python -m rail_network_graph +``` + +**Developers** (runtime + dev dependencies): + +``` bash +pip install -r requirements.txt -r requirements-dev.txt +pip install -e . +rail_network_graph +``` + +------------------------------------------------------------------------ + +## Run (Quick Start) + +1. Launch the app: + + ``` bash + rail_network_graph + ``` + +2. Enter **origin station**, **destination station**, and **start time** (`HH:MM:SS`). +3. Optionally select a **discount**. +4. Click **Search** to find the fastest connection. +5. View **route**, **distance**, **duration**, and **price** in the popup. +6. The path is highlighted on the map. +7. Click any station to see **nearest departures**. + +------------------------------------------------------------------------ + +## Project Structure + + rail_network_graph/ + ├─ __main__.py # entry point (python -m rail_network_graph) + ├─ app.py # main app: data loading, graph initialization, GUI setup + ├─ config.py # configuration (paths, constants) + ├─ logging_config.py # colored/file logging + ANSI detection + │ + ├─ assets/ + │ ├─ data/ + │ │ ├─ gtfs_data/ # routes.txt, stops.txt, trips.txt, stop_times.txt + │ │ ├─ railway_distances/ # railway_segment_lengths.txt + │ │ ├─ tickets_price/ # base_price_table.txt, discounts_percentages.txt + │ │ └─ voivodeship_border/ # geojson map boundaries + │ └─ img/icon.ico # app icon + │ + ├─ data_processing/ + │ ├─ data_loader.py # loads GTFS files into pandas DataFrames + │ └─ data_processor.py # merges and processes timetable data + │ + ├─ distance_counter/ + │ ├─ railway_graph_builder.py # builds railway graph from GeoJSON data + │ ├─ railway_route_calculator.py # computes shortest paths (km) + │ ├─ graph_snapper.py # snaps stations to nearest graph nodes + │ ├─ results_exporter.py # exports calculated distances + │ └─ distance_counter.py # pipeline entry point + │ + ├─ graphs/ + │ ├─ base_graph.py # base class for directed graph + │ └─ stations_graph.py # station-to-station graph from GTFS trips + │ + ├─ GUI/ + │ ├─ gui_creator.py # initializes and connects UI components + │ ├─ counter_panel/ + │ │ ├─ input_panel.py # input fields (station/time/discount) + │ │ ├─ submit_handler.py # search button logic (pathfinding) + │ │ └─ ticket_popup.py # popup showing route summary + │ ├─ map/ + │ │ ├─ map_canvas.py # interactive map canvas (Matplotlib) + │ │ └─ map_plotter.py # draws stations and connections + │ └─ station_info/ + │ ├─ station_info_logic.py # gets timetable info for clicked station + │ └─ station_info_popup.py # popup showing nearest departures + │ + ├─ path_finder/ + │ ├─ graph.py # builds time-expanded graph + │ ├─ path_finder.py # Dijkstra algorithm implementation + │ ├─ cleaner.py # cleans raw paths + │ ├─ models.py # data models for stops/trips + │ └─ time_utils.py # time helpers (HH:MM <-> minutes) + │ + └─ utils/ + ├─ distance_calculator.py # sums distances along route + └─ price_calculator.py # calculates ticket price from distance + +------------------------------------------------------------------------ + +## API / Module Documentation + +Full project documentation: **[API Reference - rail_network_graph](reference/rail_network_graph/index.md)**. + +------------------------------------------------------------------------ + +## License + +This project is released under **CC0-1.0** (public domain). You may +copy, modify, distribute, and use it commercially without additional +permissions. + +⚠️ **Note:** not all files in the repository are under CC0. See the +**LICENSE** file for details. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..76cb580 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,108 @@ +# Project information +site_name: RailNetworkGraph # The title of the documentation site +site_description: Docs for RailNetworkGraph # Short description of the site (used in metadata) +repo_url: https://github.com/Antek-N/RailNetworkGraph/ # Link to the GitHub repository +repo_name: Antek-N/RailNetworkGraph # Name of the repository displayed in the UI + +# Theme configuration +theme: + name: material # Use the "Material for MkDocs" theme + language: en # Interface language + font: + text: Inter # Font used for body text + code: JetBrains Mono # Font used for code blocks + features: # Enable additional theme features + - navigation.tabs # Show navigation tabs at the top + - navigation.tabs.sticky # Keep tabs visible while scrolling + - navigation.top # Back-to-top button + - navigation.sections # Group navigation items into sections + - navigation.indexes # Allow section index pages + - toc.integrate # Integrate table of contents into the navigation + - toc.follow # Highlight current section in the table of contents + - content.code.copy # Add a "copy" button to code blocks + - search.suggest # Enable search suggestions + - search.highlight # Highlight search matches in content + palette: # Define color palettes and UI themes + - media: "(prefers-color-scheme: light)" # Default light mode + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-night # Icon for switching to dark mode + name: Dark mode + - media: "(prefers-color-scheme: dark)" # Default dark mode + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny # Icon for switching back to light mode + name: Light mode + - scheme: brand # Custom "brand" color scheme + primary: deep purple + accent: cyan + toggle: + icon: material/palette + name: Brand look + - scheme: compact # Compact layout variant + primary: indigo + accent: indigo + toggle: + icon: material/format-line-spacing + name: Compact spacing + - scheme: comfy # Comfortable spacing variant + primary: indigo + accent: indigo + toggle: + icon: material/format-size + name: Comfortable size + +# Plugins extend MkDocs functionality +plugins: + - search # Built-in search engine + - autorefs # Automatic cross-references between documents + - gen-files: # Generate files dynamically + scripts: + - docs/gen_ref_pages/gen_ref_pages.py # Script for generating reference pages + - literate-nav # Define navigation structure from Markdown files + - mkdocstrings: # Auto-generate API documentation from docstrings + handlers: + python: # Handler for Python code + paths: [src] # Source code directory + options: # Rendering options + docstring_style: sphinx + docstring_section_style: table + separate_signature: true + show_signature_annotations: true + line_length: 88 + group_by_category: true + show_category_heading: true + merge_init_into_class: true + members_order: source + inherited_members: true + show_if_no_docstring: true + show_root_heading: true + show_root_toc_entry: true + show_source: false # Do not show source code links + +# Markdown extensions +markdown_extensions: + - admonition # Support for callout/admonition blocks + - footnotes # Support for footnotes + - attr_list # Allow attributes inside Markdown elements + - pymdownx.details # Collapsible details blocks + - pymdownx.superfences # Enhanced fenced code blocks + - pymdownx.tabbed: # Tabbed content + alternate_style: true + - pymdownx.highlight # Syntax highlighting for code blocks + - toc: # Table of contents + permalink: true # Add anchor links to TOC headings + +# Extra custom CSS +extra_css: + - css/mkdocstrings.css # Custom styles for mkdocstrings + - css/theme-variants.css # Custom styles for theme variants + +# Navigation (menu structure) +nav: + - Home: index.md + - Reference: reference/ \ No newline at end of file