From e478b860ac14b68d3202a20a8727ea6d9fc561a9 Mon Sep 17 00:00:00 2001 From: simonteague6 Date: Thu, 10 Sep 2026 15:09:51 -0500 Subject: [PATCH] refactor: deepen navigation module for issue 78 --- README.md | 17 +- app/logic/__init__.py | 4 +- app/logic/graph_manager.py | 202 ------------------ app/logic/navigation.py | 185 +++++++++++++++++ app/routes.py | 53 +---- app/static/js/map.js | 22 +- app/templates/index.html | 173 ++++++++-------- pyproject.toml | 1 - tests/frontend/harness.mjs | 30 +-- tests/frontend/map.test.mjs | 117 ++++------- tests/test_graph_manager.py | 397 ------------------------------------ tests/test_navigation.py | 274 +++++++++++++++++++++++++ tests/test_routes.py | 265 ++++++++++-------------- tests/test_smoke.py | 14 +- uv.lock | 232 --------------------- 15 files changed, 728 insertions(+), 1258 deletions(-) delete mode 100644 app/logic/graph_manager.py create mode 100644 app/logic/navigation.py delete mode 100644 tests/test_graph_manager.py create mode 100644 tests/test_navigation.py diff --git a/README.md b/README.md index b732c2a..7afc247 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ campus_navigator/ │ ├── routes.py ← Handles URLs: connects user input → logic → webpage │ │ │ ├── logic/ ← The "brain" of the app (pathfinding code) -│ │ ├── __init__.py ← Exports core pathfinding functions for easy imports -│ │ └── graph_manager.py ← Loads building data, runs NetworkX, returns directions +│ │ ├── __init__.py ← Exports the navigation interface +│ │ └── navigation.py ← Builds complete route outcomes from form input │ │ │ ├── static/ ← Files the browser downloads directly │ │ ├── css/ @@ -105,13 +105,12 @@ User types "Room 101" and clicks Go ↓ routes.py receives the form input ↓ -routes.py calls graph_manager.py with (start="Entrance", end="Room 101") +routes.py passes the form to navigation.py ↓ -graph_manager.py loads nodes.json and edges.json -graph_manager.py runs NetworkX shortest_path() -graph_manager.py returns: text directions + pixel coordinates +navigation.py loads map facts and runs NetworkX shortest_path() +navigation.py returns one outcome with status, directions, floors, and map-ready data ↓ -routes.py passes results to index.html (via Flask's render_template) +routes.py passes that outcome to index.html (via Flask's render_template) ↓ index.html displays the step-by-step text directions map.js reads the coordinates and draws the blue line over a coordinate grid @@ -133,9 +132,9 @@ Walk the building and define the graph. Every intersection, staircase, entrance, ### ⚙️ Backend (1–2 people) -**Workspace: `app/logic/graph_manager.py`** +**Workspace: `app/logic/navigation.py`** -Write the Python function that loads the JSON files, builds the NetworkX graph, and returns both text directions and the coordinate list for the route. This can be built and tested as a standalone Python script — no webpage needed. +Keep route rules behind the `navigate()` interface. It loads the JSON files, builds the NetworkX graph, describes floor transitions, and decides whether the route has map-ready data. Test behavior through the complete outcome returned by `navigate()`. ### 🎨 Frontend (1–2 people) diff --git a/app/logic/__init__.py b/app/logic/__init__.py index 2182c45..52dd5b7 100644 --- a/app/logic/__init__.py +++ b/app/logic/__init__.py @@ -1,3 +1,3 @@ -from .graph_manager import get_directions, get_floor_bounds, get_options +from .navigation import navigate -__all__ = ["get_directions", "get_floor_bounds", "get_options"] +__all__ = ["navigate"] diff --git a/app/logic/graph_manager.py b/app/logic/graph_manager.py deleted file mode 100644 index 00f24b5..0000000 --- a/app/logic/graph_manager.py +++ /dev/null @@ -1,202 +0,0 @@ -import json -from pathlib import Path - -import matplotlib.pyplot as plt -import networkx as nx - -# modular graph -G = None - - -# read the JSON files -def read_json_files(): - """ - Load nodes and edges JSON data using deterministic paths. - The data directory is resolved relative to the repository root, - assumed to be two levels above this file (app/logic/graph_manager.py). - """ - # Resolve repo root as two levels up from this file: app/logic -> app -> repo root - repo_root = Path(__file__).resolve().parents[2] - data_dir = repo_root / "data" - nodes_path = data_dir / "nodes.json" - edges_path = data_dir / "edges.json" - try: - with nodes_path.open("r", encoding="utf-8") as f: - nodes_data = json.load(f) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeError( - f"Failed to load nodes data from {nodes_path}: {exc}" - ) from exc - try: - with edges_path.open("r", encoding="utf-8") as f: - edges_data = json.load(f) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeError( - f"Failed to load edges data from {edges_path}: {exc}" - ) from exc - - return nodes_data, edges_data - - -def build_graph(): - nodes_data, edges_data = read_json_files() - # create a directed graph - G = nx.DiGraph() - # method to add nodes and edges - for node in nodes_data: - node_id = node["id"] - G.add_node(node_id, **node) - for edge in edges_data: - G.add_edge(edge["source"], edge["target"], **edge) - return G - - -# should be used when graph is updated, to reload the graph with new data -def reload_graph(): - global G - G = build_graph() - - -def shortest_route(graph, start, end): - try: - return nx.shortest_path(graph, source=start, target=end, weight="weight") - except nx.NetworkXNoPath, nx.NodeNotFound: - return None - - -def get_floor_bounds(building, floor): - repo_root = Path(__file__).resolve().parents[2] - floors_path = repo_root / "data" / "floors.json" - try: - with floors_path.open("r", encoding="utf-8") as f: - floors_data = json.load(f) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeError( - f"Failed to load floors data from {floors_path}: {exc}" - ) from exc - floor_str = str(floor) - try: - floor_info = floors_data[building][floor_str] - except KeyError: - raise RuntimeError(f"No floor data for {building} floor {floor_str}") - return {"width": floor_info["width_feet"], "height": floor_info["height_feet"]} - - -def get_options(): - nodes_data, _ = read_json_files() - entrances = [ - {"id": n["id"], "name": n["name"]} - for n in nodes_data - if n["type"] in ("elevator", "staircase") - ] - classrooms = [ - {"id": n["id"], "name": n["name"]} for n in nodes_data if n["type"] == "room" - ] - return entrances, classrooms - - -# method to get direction -def get_directions(start, end): - - if G is None: - reload_graph() - - path = shortest_route(G, start, end) - if path is None: - return f"No path found from {start} to {end}." - directions = [] - coordinates = [] - if start == end: - directions.append("You are already at your destination.") - current_node = G.nodes[start] - coordinates.append( - { - "x": current_node["coords"][0], - "y": current_node["coords"][1], - "floor": current_node["floor"], - } - ) - return {"directions": directions, "coordinates": coordinates} - for i in range(len(path)): - current_node_id = path[i] - current_node = G.nodes[current_node_id] - - coordinates.append( - { - "x": current_node["coords"][0], - "y": current_node["coords"][1], - "floor": current_node["floor"], - } - ) - - if i == len(path) - 1: - break - - next_node_id = path[i + 1] - next_node = G.nodes[next_node_id] - edge_data = G.get_edge_data(current_node_id, next_node_id) - - # Detects when route changes floors - # Staircases are treated as nodes and act as a transition point between floors, so we can check if the next node is a staircase and if the floor changes - - if current_node["floor"] != next_node["floor"]: - if ( - current_node.get("type") == "staircase" - or next_node.get("type") == "staircase" - ): - directions.append( - f"Take the stairs from floor {current_node['floor']} to floor {next_node['floor']}." - ) - elif ( - current_node.get("type") == "elevator" - and next_node.get("type") == "elevator" - ): - directions.append(edge_data["instruction"]) - else: - directions.append( - f"Move from floor {current_node['floor']} to floor {next_node['floor']}." - ) - else: - directions.append(edge_data["instruction"]) - - return {"directions": directions, "coordinates": coordinates} - - -# builds photo of the graph -def main(): - global G - nodes_data, _ = read_json_files() - G = build_graph() - # print(f"Successfully built graph!") - # print(f"Nodes: {G.number_of_nodes()}") - # print(f"Edges: {G.number_of_edges()}") - pos = { - node["id"]: tuple(node["coords"]) - for node in nodes_data - if node.get("coords") and node["coords"][0] is not None - } - positioned_nodes = list(pos.keys()) - G_subgraph = G.subgraph(positioned_nodes).copy() - nx.draw( - G_subgraph, - pos, - with_labels=True, - node_color="skyblue", - node_size=2000, - edge_color="black", - font_size=10, - font_weight="bold", - arrowsize=20, - ) - # plt.show() - plt.savefig("map_preview.png") - print("Map saved as map_preview.png - check your file list!") - # print(G.edges()) - - # #calcs shortest path between room_101 and room_102 - # print(shortest_route('room_101', 'room_102')) - print(get_directions("NPB_5_102", "NPB_5_E1")) - - -if __name__ == "__main__": - main() diff --git a/app/logic/navigation.py b/app/logic/navigation.py new file mode 100644 index 0000000..a77f77a --- /dev/null +++ b/app/logic/navigation.py @@ -0,0 +1,185 @@ +import json +from collections.abc import Mapping +from itertools import pairwise +from pathlib import Path +from typing import Any + +import networkx as nx + +_DATA_DIR = Path(__file__).resolve().parents[2] / "data" +_graph: nx.DiGraph | None = None + + +def navigate(selection: Mapping[str, str] | None = None) -> dict[str, Any]: + """Build the complete template model for one navigation request.""" + try: + nodes, edges, floors = _load_map_facts() + except RuntimeError as exc: + return _outcome(status="error", error=str(exc)) + + outcome = _outcome( + entrances=_options(nodes, {"elevator", "staircase"}), + classrooms=_options(nodes, {"room"}), + ) + if selection is None: + return outcome + + start = selection.get("entrance") + destination = selection.get("classroom") + if not start or not destination: + outcome.update(status="error", error="Missing entrance or classroom parameter") + return outcome + + graph = _get_graph(nodes, edges) + try: + path = nx.shortest_path( + graph, source=start, target=destination, weight="weight" + ) + except nx.NetworkXNoPath, nx.NodeNotFound: + outcome.update( + status="error", + error=( + "No route could be found between the selected entrance and " + "destination. Please check your selections and try again." + ), + ) + return outcome + + directions, coordinates = _describe_path(graph, path) + traversed_floors = list(dict.fromkeys(point["floor"] for point in coordinates)) + outcome.update( + status="success", + directions=directions, + traversed_floors=traversed_floors, + ) + + if not coordinates: + outcome["map"] = { + "status": "unavailable", + "data": None, + "message": "Map preview is unavailable for this route.", + } + elif len(traversed_floors) > 1: + outcome["map"] = { + "status": "unavailable", + "data": None, + "message": ( + "Map preview is unavailable for routes spanning multiple floors. " + "Follow the directions panel for this route." + ), + } + else: + first_node = graph.nodes[path[0]] + try: + bounds = _floor_bounds(floors, first_node["building"], traversed_floors[0]) + except RuntimeError as exc: + outcome.update(status="error", error=str(exc)) + return outcome + outcome["map"] = { + "status": "ready", + "data": {"coordinates": coordinates, "bounds": bounds}, + "message": None, + } + + return outcome + + +def _outcome( + *, + status: str = "idle", + error: str | None = None, + entrances: list[dict[str, str]] | None = None, + classrooms: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + return { + "status": status, + "error": error, + "directions": [], + "traversed_floors": [], + "map": {"status": "idle", "data": None, "message": None}, + "options": { + "entrances": entrances or [], + "classrooms": classrooms or [], + }, + } + + +def _load_map_facts() -> tuple[list[dict], list[dict], dict]: + return ( + _read_json("nodes.json", "nodes"), + _read_json("edges.json", "edges"), + _read_json("floors.json", "floors"), + ) + + +def _read_json(filename: str, label: str): + path = _DATA_DIR / filename + try: + with path.open("r", encoding="utf-8") as file: + return json.load(file) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Failed to load {label} data from {path}: {exc}") from exc + + +def _options(nodes: list[dict], node_types: set[str]) -> list[dict[str, str]]: + return [ + {"id": node["id"], "name": node["name"]} + for node in nodes + if node["type"] in node_types + ] + + +def _get_graph(nodes: list[dict], edges: list[dict]) -> nx.DiGraph: + global _graph + if _graph is None: + graph = nx.DiGraph() + for node in nodes: + graph.add_node(node["id"], **node) + for edge in edges: + graph.add_edge(edge["source"], edge["target"], **edge) + _graph = graph + return _graph + + +def _describe_path( + graph: nx.DiGraph, path: list[str] +) -> tuple[list[str], list[dict[str, int | float]]]: + coordinates = [_coordinate(graph.nodes[node_id]) for node_id in path] + if len(path) == 1: + return ["You are already at your destination."], coordinates + + directions = [ + _transition_instruction( + graph.nodes[current_id], + graph.nodes[next_id], + graph.get_edge_data(current_id, next_id), + ) + for current_id, next_id in pairwise(path) + ] + return directions, coordinates + + +def _coordinate(node: dict) -> dict[str, int | float]: + return {"x": node["coords"][0], "y": node["coords"][1], "floor": node["floor"]} + + +def _transition_instruction(current: dict, next_node: dict, edge: dict) -> str: + if current["floor"] == next_node["floor"]: + return edge["instruction"] + if "staircase" in {current.get("type"), next_node.get("type")}: + return ( + f"Take the stairs from floor {current['floor']} " + f"to floor {next_node['floor']}." + ) + if current.get("type") == next_node.get("type") == "elevator": + return edge["instruction"] + return f"Move from floor {current['floor']} to floor {next_node['floor']}." + + +def _floor_bounds(floors: dict, building: str, floor: int) -> dict[str, Any]: + floor_key = str(floor) + try: + floor_info = floors[building][floor_key] + except KeyError as exc: + raise RuntimeError(f"No floor data for {building} floor {floor_key}") from exc + return {"width": floor_info["width_feet"], "height": floor_info["height_feet"]} diff --git a/app/routes.py b/app/routes.py index cd887f5..e734824 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,64 +1,21 @@ from flask import Blueprint, jsonify, render_template, request -from app.logic import get_directions, get_floor_bounds, get_options +from app.logic import navigate bp = Blueprint("main", __name__) @bp.route("/", methods=["GET"]) def index(): - entrances, classrooms = get_options() - return render_template( - "index.html", entranceOptions=entrances, classroomOptions=classrooms - ) + return render_template("index.html", outcome=navigate()) @bp.route("/directions", methods=["POST"]) def directions(): - steps = None - coordinates = None - floor_bounds = None - map_error = None - error = None - entrance = request.form.get("entrance") - classroom = request.form.get("classroom") - if not entrance or not classroom: - error = "Missing entrance or classroom parameter" - else: - try: - result = get_directions(entrance, classroom) - if isinstance(result, str): - error = "No route could be found between the selected entrance and destination. Please check your selections and try again." - else: - steps = result["directions"] - coordinates = result["coordinates"] - if coordinates: - route_floors = {coordinate["floor"] for coordinate in coordinates} - if len(route_floors) > 1: - map_error = ( - "Map preview is unavailable for routes spanning multiple floors. " - "Follow the directions panel for this route." - ) - else: - building = entrance.split("_")[0] - floor = coordinates[0]["floor"] - floor_bounds = get_floor_bounds(building, floor) - except RuntimeError as e: - error = str(e) - entrances, classrooms = get_options() - return render_template( - "index.html", - steps=steps, - coordinates=coordinates, - floor_bounds=floor_bounds, - map_error=map_error, - error=error, - entranceOptions=entrances, - classroomOptions=classrooms, - ) + return render_template("index.html", outcome=navigate(request.form)) @bp.route("/api/test") def test(): - directions = get_directions("NPB_5_E1", "NPB_5_154") - return jsonify(directions) + outcome = navigate({"entrance": "NPB_5_E1", "classroom": "NPB_5_154"}) + return jsonify(outcome) diff --git a/app/static/js/map.js b/app/static/js/map.js index 9475ac7..88f8007 100644 --- a/app/static/js/map.js +++ b/app/static/js/map.js @@ -1,25 +1,17 @@ document.addEventListener("DOMContentLoaded", () => { - const { routeCoordinates, floorBounds } = window; - if (!routeCoordinates || !floorBounds) { - console.error("routeCoordinates or floorBounds missing from template"); + const { routeMap } = window; + if (!routeMap) { + console.error("routeMap missing from template"); return; } if (!window.L) { console.error("Leaflet is missing from the page"); return; } - if (!Array.isArray(routeCoordinates)) { - console.error("routeCoordinates must be an array"); - return; - } - const floors = new Set( - routeCoordinates - .map((coordinate) => coordinate.floor) - .filter((floor) => floor !== undefined), - ); - if (floors.size > 1) { - console.error("Map preview only supports routes on one floor"); + const { coordinates, bounds: floorBounds } = routeMap; + if (!Array.isArray(coordinates) || !floorBounds) { + console.error("routeMap must contain coordinates and bounds"); return; } @@ -77,7 +69,7 @@ document.addEventListener("DOMContentLoaded", () => { drawGrid(); // Convert backend coordinates → Leaflet [lat, lng] - const latlngs = routeCoordinates.map((c) => [c.y, c.x]); + const latlngs = coordinates.map((coordinate) => [coordinate.y, coordinate.x]); if (latlngs.length > 0) { // Draw animated route line diff --git a/app/templates/index.html b/app/templates/index.html index 94edca7..593dcee 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,92 +1,81 @@ - - - - - MiniMap - Dropdowns - - - - - -
-
-

Choose Your Options

-
- - -
-
- - -
-
- -
-
- Route: - - - -
- {% if error %} -
-

{{ error }}

-
- {% endif %} - {% if steps %} -
-

Directions

-
- - -
- {% endif %} -
-
- {% if map_error %} -
-

{{ map_error }}

-
- {% elif coordinates %} -
- - - - {% else %} -
-

Map will appear here after you select directions

-
- {% endif %} -
-
- - + + + + + MiniMap - Dropdowns + + + + + +
+
+

Choose Your Options

+ + + +
+
+ + +
+
+ +
+
+ Route: + + + +
+ {% if outcome.error %} +
+

{{ outcome.error }}

+
+ {% endif %} + {% if outcome.directions %} +
+

Directions

+
+ + +
+ {% endif %} +
+
+ {% if outcome.map.status == "ready" %} +
+ + + + {% else %} +
+

{{ outcome.map.message or "Map will appear here after you select directions" }}

+
+ {% endif %} +
+
+ + diff --git a/pyproject.toml b/pyproject.toml index 7f60733..3ef9fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ requires-python = ">=3.14" dependencies = [ "flask>=3.1.2", "gunicorn>=25.3.0", - "matplotlib>=3.10.8", "networkx>=3.6.1", ] diff --git a/tests/frontend/harness.mjs b/tests/frontend/harness.mjs index 1d59ce6..db21b9f 100644 --- a/tests/frontend/harness.mjs +++ b/tests/frontend/harness.mjs @@ -410,8 +410,7 @@ function createConsoleRecorder() { */ export function createBrowserHarness({ directionSteps, - routeCoordinates, - floorBounds, + routeMap, windowProperties = {}, globals = {}, } = {}) { @@ -437,11 +436,8 @@ export function createBrowserHarness({ if (directionSteps !== undefined) { window.directionSteps = directionSteps; } - if (routeCoordinates !== undefined) { - window.routeCoordinates = routeCoordinates; - } - if (floorBounds !== undefined) { - window.floorBounds = floorBounds; + if (routeMap !== undefined) { + window.routeMap = routeMap; } document.defaultView = window; @@ -476,11 +472,8 @@ export function createBrowserHarness({ if (directionSteps !== undefined) { context.directionSteps = directionSteps; } - if (routeCoordinates !== undefined) { - context.routeCoordinates = routeCoordinates; - } - if (floorBounds !== undefined) { - context.floorBounds = floorBounds; + if (routeMap !== undefined) { + context.routeMap = routeMap; } const harness = { @@ -560,8 +553,7 @@ function prepareLoad(scriptOptions) { options.harness ?? createBrowserHarness({ directionSteps: options.directionSteps, - routeCoordinates: options.routeCoordinates, - floorBounds: options.floorBounds, + routeMap: options.routeMap, windowProperties: options.windowProperties, globals: options.globals, }); @@ -573,13 +565,9 @@ function applyGlobals(harness, options) { harness.window.directionSteps = options.directionSteps; harness.context.directionSteps = options.directionSteps; } - if (options.routeCoordinates !== undefined) { - harness.window.routeCoordinates = options.routeCoordinates; - harness.context.routeCoordinates = options.routeCoordinates; - } - if (options.floorBounds !== undefined) { - harness.window.floorBounds = options.floorBounds; - harness.context.floorBounds = options.floorBounds; + if (options.routeMap !== undefined) { + harness.window.routeMap = options.routeMap; + harness.context.routeMap = options.routeMap; } for (const [name, value] of Object.entries(options.globals ?? {})) { harness.window[name] = value; diff --git a/tests/frontend/map.test.mjs b/tests/frontend/map.test.mjs index 9d77997..31a2de2 100644 --- a/tests/frontend/map.test.mjs +++ b/tests/frontend/map.test.mjs @@ -9,49 +9,47 @@ const template = readFileSync( "utf8", ); -test("map stops with a useful error when template globals are missing", () => { - const cases = [ - { - name: "routeCoordinates", - options: { floorBounds: { width: 30, height: 20 } }, - }, - { name: "floorBounds", options: { routeCoordinates: [{ x: 2, y: 3 }] } }, - ]; +function mapData(coordinates, extra = {}) { + return { + routeMap: { coordinates, bounds: { width: 30, height: 20 } }, + ...extra, + }; +} - for (const { name, options } of cases) { - const harness = loadMapScript(options); +test("map stops with a useful error when routeMap is missing", () => { + const harness = loadMapScript(); - assert.equal(harness.maps.length, 0, `${name} should prevent map setup`); - assert.equal( - harness.layers.length, - 0, - `${name} should prevent layer setup`, - ); - assert.deepEqual(harness.consoleMessages.error, [ - ["routeCoordinates or floorBounds missing from template"], - ]); - } + assert.equal(harness.maps.length, 0); + assert.equal(harness.layers.length, 0); + assert.deepEqual(harness.consoleMessages.error, [ + ["routeMap missing from template"], + ]); +}); + +test("map rejects routeMap data without coordinates and bounds", () => { + const harness = loadMapScript({ routeMap: { coordinates: [] } }); + + assert.equal(harness.maps.length, 0); + assert.deepEqual(harness.consoleMessages.error, [ + ["routeMap must contain coordinates and bounds"], + ]); }); test("map template wires data globals before loading the browser scripts", () => { const directionsDataIndex = template.indexOf("const directionSteps"); const directionsScriptIndex = template.indexOf("js/directions.js"); - const mapDataIndex = template.indexOf("window.routeCoordinates"); + const mapDataIndex = template.indexOf("window.routeMap"); const mapScriptIndex = template.indexOf("js/map.js"); assert.match(template, /id="directions-card-container"/); assert.match( template, - /const directionSteps\s*=\s*\{\{\s*steps\s*\|\s*tojson/, + /const directionSteps\s*=\s*\{\{\s*outcome\.directions\s*\|\s*tojson/, ); assert.match(template, /id="map"/); assert.match( template, - /window\.routeCoordinates\s*=\s*\{\{\s*coordinates\s*\|\s*tojson/, - ); - assert.match( - template, - /window\.floorBounds\s*=\s*\{\{\s*floor_bounds\s*\|\s*tojson/, + /window\.routeMap\s*=\s*\{\{\s*outcome\.map\.data\s*\|\s*tojson/, ); assert.ok(directionsDataIndex >= 0); assert.ok(directionsDataIndex < directionsScriptIndex); @@ -60,11 +58,9 @@ test("map template wires data globals before loading the browser scripts", () => }); test("map stops when Leaflet is missing", () => { - const harness = loadMapScript({ - routeCoordinates: [{ x: 2, y: 3, floor: 1 }], - floorBounds: { width: 30, height: 20 }, - globals: { L: undefined }, - }); + const harness = loadMapScript( + mapData([{ x: 2, y: 3, floor: 1 }], { globals: { L: undefined } }), + ); assert.equal(harness.maps.length, 0); assert.deepEqual(harness.consoleMessages.error, [ @@ -72,26 +68,8 @@ test("map stops when Leaflet is missing", () => { ]); }); -test("map declines to draw a route that crosses floors", () => { - const harness = loadMapScript({ - routeCoordinates: [ - { x: 2, y: 3, floor: 1 }, - { x: 4, y: 5, floor: 2 }, - ], - floorBounds: { width: 30, height: 20 }, - }); - - assert.equal(harness.maps.length, 0); - assert.deepEqual(harness.consoleMessages.error, [ - ["Map preview only supports routes on one floor"], - ]); -}); - test("map creates the bounded map without a route for empty coordinates", () => { - const harness = loadMapScript({ - routeCoordinates: [], - floorBounds: { width: 30, height: 20 }, - }); + const harness = loadMapScript(mapData([])); assert.equal(harness.maps.length, 1); assert.equal( @@ -106,13 +84,12 @@ test("map creates the bounded map without a route for empty coordinates", () => }); test("map uses Simple CRS, configured zoom options, and floor bounds", () => { - const harness = loadMapScript({ - routeCoordinates: [ + const harness = loadMapScript( + mapData([ { x: 4, y: 2 }, { x: 28, y: 18 }, - ], - floorBounds: { width: 30, height: 20 }, - }); + ]), + ); const map = harness.maps[0]; assert.equal(map.container, "map"); @@ -132,10 +109,7 @@ test("map uses Simple CRS, configured zoom options, and floor bounds", () => { }); test("map draws a 10-foot grid across the floor dimensions", () => { - const harness = loadMapScript({ - routeCoordinates: [{ x: 5, y: 5 }], - floorBounds: { width: 30, height: 20 }, - }); + const harness = loadMapScript(mapData([{ x: 5, y: 5 }])); const grid = harness.layers.filter( (layer) => layer.type === "polyline" && layer.options.color === "#cccccc", ); @@ -184,13 +158,12 @@ test("map draws a 10-foot grid across the floor dimensions", () => { }); test("map converts backend x/y points and styles the route line", () => { - const harness = loadMapScript({ - routeCoordinates: [ + const harness = loadMapScript( + mapData([ { x: 12, y: 4 }, { x: 20, y: 15 }, - ], - floorBounds: { width: 30, height: 20 }, - }); + ]), + ); const routeLine = harness.layers.find( (layer) => layer.type === "polyline" && layer.options.className === "animated-route", @@ -216,13 +189,12 @@ test("map converts backend x/y points and styles the route line", () => { }); test("map marks the route start green and end red", () => { - const harness = loadMapScript({ - routeCoordinates: [ + const harness = loadMapScript( + mapData([ { x: 3, y: 7 }, { x: 22, y: 11 }, - ], - floorBounds: { width: 30, height: 20 }, - }); + ]), + ); const markers = harness.layers.filter( (layer) => layer.type === "circleMarker", ); @@ -246,10 +218,7 @@ test("map marks the route start green and end red", () => { }); test("map invalidates its size on the resize event and deferred startup check", () => { - const harness = loadMapScript({ - routeCoordinates: [{ x: 5, y: 5 }], - floorBounds: { width: 30, height: 20 }, - }); + const harness = loadMapScript(mapData([{ x: 5, y: 5 }])); const map = harness.maps[0]; assert.equal(harness.window.listenerCount("resize"), 1); diff --git a/tests/test_graph_manager.py b/tests/test_graph_manager.py deleted file mode 100644 index 04e81a2..0000000 --- a/tests/test_graph_manager.py +++ /dev/null @@ -1,397 +0,0 @@ -import json - -import networkx as nx -import pytest - -from app import logic -from app.logic import graph_manager as manager - - -@pytest.fixture(autouse=True) -def reset_global_graph(): - manager.G = None - yield - manager.G = None - - -def configure_data_root(monkeypatch, tmp_path): - repo_root = tmp_path / "repo" - logic_path = repo_root / "app" / "logic" - data_path = repo_root / "data" - logic_path.mkdir(parents=True) - data_path.mkdir() - monkeypatch.setattr(manager, "__file__", str(logic_path / "graph_manager.py")) - return data_path - - -def write_json(path, value): - path.write_text(json.dumps(value), encoding="utf-8") - - -def make_graph(*nodes, edges=()): - graph = nx.DiGraph() - for node_id, attributes in nodes: - graph.add_node(node_id, **attributes) - for source, target, attributes in edges: - graph.add_edge(source, target, **attributes) - return graph - - -def node(floor=1, node_type="waypoint", x=0, y=0, name="Node"): - return {"floor": floor, "type": node_type, "coords": [x, y], "name": name} - - -def test_read_json_files_loads_nodes_and_edges(monkeypatch, tmp_path): - data_path = configure_data_root(monkeypatch, tmp_path) - nodes = [{"id": "a", "type": "room"}] - edges = [{"source": "a", "target": "a", "weight": 0}] - write_json(data_path / "nodes.json", nodes) - write_json(data_path / "edges.json", edges) - - assert manager.read_json_files() == (nodes, edges) - - -@pytest.mark.parametrize( - ("missing_file", "message"), - [ - ("nodes.json", "Failed to load nodes data"), - ("edges.json", "Failed to load edges data"), - ], -) -def test_read_json_files_reports_missing_data( - monkeypatch, tmp_path, missing_file, message -): - data_path = configure_data_root(monkeypatch, tmp_path) - write_json(data_path / "nodes.json", []) - write_json(data_path / "edges.json", []) - (data_path / missing_file).unlink() - - with pytest.raises(RuntimeError, match=message): - manager.read_json_files() - - -@pytest.mark.parametrize( - ("invalid_file", "message"), - [ - ("nodes.json", "Failed to load nodes data"), - ("edges.json", "Failed to load edges data"), - ], -) -def test_read_json_files_reports_invalid_json( - monkeypatch, tmp_path, invalid_file, message -): - data_path = configure_data_root(monkeypatch, tmp_path) - write_json(data_path / "nodes.json", []) - write_json(data_path / "edges.json", []) - (data_path / invalid_file).write_text("not json", encoding="utf-8") - - with pytest.raises(RuntimeError, match=message): - manager.read_json_files() - - -def test_read_json_files_uses_module_path_instead_of_working_directory( - monkeypatch, tmp_path -): - data_path = configure_data_root(monkeypatch, tmp_path) - nodes = [{"id": "from-module-path"}] - edges = [{"source": "from-module-path", "target": "from-module-path"}] - write_json(data_path / "nodes.json", nodes) - write_json(data_path / "edges.json", edges) - other_directory = tmp_path / "unrelated-working-directory" - other_directory.mkdir() - monkeypatch.chdir(other_directory) - - assert manager.read_json_files() == (nodes, edges) - - -def test_build_graph_preserves_node_and_edge_data(monkeypatch): - nodes = [ - {"id": "a", "name": "A", "floor": 1}, - {"id": "b", "name": "B", "floor": 2}, - ] - edges = [{"source": "a", "target": "b", "weight": 4, "instruction": "Go up."}] - monkeypatch.setattr(manager, "read_json_files", lambda: (nodes, edges)) - - graph = manager.build_graph() - - assert isinstance(graph, nx.DiGraph) - assert graph.nodes["a"] == nodes[0] - assert graph["a"]["b"] == edges[0] - assert not graph.has_edge("b", "a") - - -def test_reload_graph_replaces_cached_graph(monkeypatch): - graph = nx.DiGraph() - monkeypatch.setattr(manager, "build_graph", lambda: graph) - - manager.reload_graph() - - assert manager.G is graph - - -def test_shortest_route_uses_edge_weights_and_handles_missing_routes(): - graph = nx.DiGraph() - graph.add_weighted_edges_from( - [("start", "direct", 10), ("start", "detour", 2), ("detour", "direct", 3)] - ) - - assert manager.shortest_route(graph, "start", "direct") == [ - "start", - "detour", - "direct", - ] - assert manager.shortest_route(graph, "direct", "start") is None - assert manager.shortest_route(graph, "unknown", "start") is None - - -def test_shortest_route_returns_the_start_node_for_same_node_routes(): - graph = nx.DiGraph() - graph.add_node("room") - - assert manager.shortest_route(graph, "room", "room") == ["room"] - - -def test_get_floor_bounds_reads_dimensions(monkeypatch, tmp_path): - data_path = configure_data_root(monkeypatch, tmp_path) - write_json( - data_path / "floors.json", - {"BLDG": {"2": {"width_feet": 120, "height_feet": 80}}}, - ) - - assert manager.get_floor_bounds("BLDG", 2) == {"width": 120, "height": 80} - - -@pytest.mark.parametrize( - ("floors_data", "building", "floor", "message"), - [ - ({"BLDG": {}}, "BLDG", 2, "No floor data for BLDG floor 2"), - ({}, "BLDG", 2, "No floor data for BLDG floor 2"), - ], -) -def test_get_floor_bounds_rejects_unknown_floor( - monkeypatch, tmp_path, floors_data, building, floor, message -): - data_path = configure_data_root(monkeypatch, tmp_path) - write_json(data_path / "floors.json", floors_data) - - with pytest.raises(RuntimeError, match=message): - manager.get_floor_bounds(building, floor) - - -def test_get_floor_bounds_reports_invalid_json(monkeypatch, tmp_path): - data_path = configure_data_root(monkeypatch, tmp_path) - (data_path / "floors.json").write_text("not json", encoding="utf-8") - - with pytest.raises(RuntimeError, match="Failed to load floors data"): - manager.get_floor_bounds("BLDG", 1) - - -def test_get_floor_bounds_reports_missing_file(monkeypatch, tmp_path): - configure_data_root(monkeypatch, tmp_path) - - with pytest.raises(RuntimeError, match="Failed to load floors data"): - manager.get_floor_bounds("BLDG", 1) - - -def test_get_options_separates_connectors_and_rooms(monkeypatch): - nodes = [ - {"id": "e", "name": "Elevator", "type": "elevator"}, - {"id": "s", "name": "Stairs", "type": "staircase"}, - {"id": "r", "name": "Room", "type": "room"}, - {"id": "w", "name": "Waypoint", "type": "waypoint"}, - ] - monkeypatch.setattr(manager, "read_json_files", lambda: (nodes, [])) - - entrances, classrooms = manager.get_options() - - assert entrances == [ - {"id": "e", "name": "Elevator"}, - {"id": "s", "name": "Stairs"}, - ] - assert classrooms == [{"id": "r", "name": "Room"}] - - -def test_get_directions_returns_instructions_and_route_coordinates(): - manager.G = make_graph( - ("start", node(floor=1, x=1, y=2)), - ("middle", node(floor=1, x=3, y=4)), - ("end", node(floor=1, x=5, y=6)), - edges=[ - ("start", "middle", {"weight": 1, "instruction": "Walk ahead."}), - ("middle", "end", {"weight": 1, "instruction": "Turn right."}), - ], - ) - - result = manager.get_directions("start", "end") - - assert result == { - "directions": ["Walk ahead.", "Turn right."], - "coordinates": [ - {"x": 1, "y": 2, "floor": 1}, - {"x": 3, "y": 4, "floor": 1}, - {"x": 5, "y": 6, "floor": 1}, - ], - } - - -def test_get_directions_handles_same_start_and_destination(): - manager.G = make_graph(("room", node(floor=5, x=12, y=-3))) - - assert manager.get_directions("room", "room") == { - "directions": ["You are already at your destination."], - "coordinates": [{"x": 12, "y": -3, "floor": 5}], - } - - -@pytest.mark.parametrize( - ("current_type", "next_type", "expected"), - [ - ("staircase", "waypoint", "Take the stairs from floor 1 to floor 2."), - ("waypoint", "staircase", "Take the stairs from floor 1 to floor 2."), - ("elevator", "waypoint", "Move from floor 1 to floor 2."), - ("waypoint", "elevator", "Move from floor 1 to floor 2."), - ("waypoint", "waypoint", "Move from floor 1 to floor 2."), - ], -) -def test_get_directions_describes_floor_changes(current_type, next_type, expected): - manager.G = make_graph( - ("start", node(floor=1, node_type=current_type)), - ("end", node(floor=2, node_type=next_type)), - edges=[("start", "end", {"weight": 1, "instruction": "Ignore me."})], - ) - - result = manager.get_directions("start", "end") - - assert result["directions"] == [expected] - assert [coordinate["floor"] for coordinate in result["coordinates"]] == [1, 2] - - -def test_get_directions_preserves_generated_elevator_instruction_between_connectors(): - manager.G = make_graph( - ("NPB_5_E1", node(floor=5, node_type="elevator")), - ("NPB_4_E1", node(floor=4, node_type="elevator")), - edges=[ - ( - "NPB_5_E1", - "NPB_4_E1", - { - "weight": 20, - "instruction": "Take elevator E1 to floor 4.", - }, - ) - ], - ) - - result = manager.get_directions("NPB_5_E1", "NPB_4_E1") - - assert result["directions"] == ["Take elevator E1 to floor 4."] - - -def test_get_directions_returns_message_when_no_path_exists(): - manager.G = make_graph(("start", node()), ("end", node())) - - assert manager.get_directions("start", "end") == "No path found from start to end." - - -@pytest.mark.parametrize( - ("start", "end"), - [("missing", "end"), ("start", "missing")], -) -def test_get_directions_returns_message_for_missing_nodes(start, end): - manager.G = make_graph(("start", node()), ("end", node())) - - assert manager.get_directions(start, end) == ( - f"No path found from {start} to {end}." - ) - - -def test_get_directions_uses_weighted_route_instructions(): - manager.G = make_graph( - ("start", node(x=0, y=0)), - ("direct", node(x=10, y=0)), - ("detour", node(x=2, y=0)), - ("end", node(x=4, y=0)), - edges=[ - ("start", "direct", {"weight": 10, "instruction": "Take direct."}), - ("direct", "end", {"weight": 10, "instruction": "Finish direct."}), - ("start", "detour", {"weight": 2, "instruction": "Take detour."}), - ("detour", "end", {"weight": 3, "instruction": "Finish detour."}), - ], - ) - - result = manager.get_directions("start", "end") - - assert result["directions"] == ["Take detour.", "Finish detour."] - assert [coordinate["x"] for coordinate in result["coordinates"]] == [0, 2, 4] - - -def test_get_directions_reuses_cached_graph(monkeypatch): - manager.G = make_graph( - ("start", node()), - ("end", node()), - edges=[("start", "end", {"weight": 1, "instruction": "Arrive."})], - ) - reload_calls = [] - monkeypatch.setattr(manager, "reload_graph", lambda: reload_calls.append(True)) - - assert manager.get_directions("start", "end")["directions"] == ["Arrive."] - assert reload_calls == [] - - -def test_get_directions_lazily_loads_graph(monkeypatch): - graph = make_graph( - ("start", node()), - ("end", node()), - edges=[("start", "end", {"weight": 1, "instruction": "Arrive."})], - ) - - def load_graph(): - manager.G = graph - - monkeypatch.setattr(manager, "reload_graph", load_graph) - - assert manager.get_directions("start", "end")["directions"] == ["Arrive."] - - -def test_main_draws_positioned_subgraph_and_saves_preview(monkeypatch): - nodes = [ - {"id": "positioned", "coords": [10, 20]}, - {"id": "unpositioned", "coords": [None, 30]}, - ] - graph = make_graph( - ("positioned", node(x=10, y=20)), - ("unpositioned", node(x=None, y=30)), - edges=[("positioned", "unpositioned", {"weight": 1, "instruction": "Go."})], - ) - drawn = [] - saved = [] - direction_calls = [] - monkeypatch.setattr(manager, "read_json_files", lambda: (nodes, [])) - monkeypatch.setattr(manager, "build_graph", lambda: graph) - monkeypatch.setattr( - manager.nx, - "draw", - lambda graph, pos, **kwargs: drawn.append((graph, pos, kwargs)), - ) - monkeypatch.setattr(manager.plt, "savefig", lambda path: saved.append(path)) - monkeypatch.setattr( - manager, - "get_directions", - lambda start, end: direction_calls.append((start, end)), - ) - - manager.main() - - assert manager.G is graph - assert len(drawn) == 1 - assert set(drawn[0][0].nodes) == {"positioned"} - assert drawn[0][1] == {"positioned": (10, 20)} - assert saved == ["map_preview.png"] - assert direction_calls == [("NPB_5_102", "NPB_5_E1")] - - -def test_logic_exports_public_graph_manager_api(): - assert logic.__all__ == ["get_directions", "get_floor_bounds", "get_options"] - assert logic.get_directions is manager.get_directions - assert logic.get_floor_bounds is manager.get_floor_bounds - assert logic.get_options is manager.get_options diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..e38e657 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,274 @@ +import json + +import networkx as nx +import pytest + +from app import logic +from app.logic import navigation + + +@pytest.fixture(autouse=True) +def reset_graph_cache(): + navigation._graph = None + yield + navigation._graph = None + + +def node(node_id, *, floor=1, node_type="waypoint", x=0, y=0, building="BLDG"): + return { + "id": node_id, + "name": node_id, + "type": node_type, + "building": building, + "coords": [x, y], + "floor": floor, + } + + +def facts(nodes, edges=(), floors=None): + if floors is None: + floors = {"BLDG": {"1": {"width_feet": 100, "height_feet": 80}}} + return nodes, list(edges), floors + + +def select(start="start", destination="end"): + return {"entrance": start, "classroom": destination} + + +def test_idle_outcome_contains_options(monkeypatch): + nodes = [ + node("elevator", node_type="elevator"), + node("stairs", node_type="staircase"), + node("room", node_type="room"), + node("hall"), + ] + monkeypatch.setattr(navigation, "_load_map_facts", lambda: facts(nodes)) + + outcome = navigation.navigate() + + assert outcome["status"] == "idle" + assert outcome["options"] == { + "entrances": [ + {"id": "elevator", "name": "elevator"}, + {"id": "stairs", "name": "stairs"}, + ], + "classrooms": [{"id": "room", "name": "room"}], + } + assert outcome["map"]["status"] == "idle" + + +@pytest.mark.parametrize( + "selection", + [ + {}, + {"entrance": "start"}, + {"classroom": "end"}, + {"entrance": "", "classroom": "end"}, + ], +) +def test_navigation_rejects_missing_selection(monkeypatch, selection): + monkeypatch.setattr(navigation, "_load_map_facts", lambda: facts([])) + + outcome = navigation.navigate(selection) + + assert outcome["status"] == "error" + assert outcome["error"] == "Missing entrance or classroom parameter" + + +def test_navigation_returns_one_map_ready_outcome_for_weighted_path(monkeypatch): + nodes = [ + node("start", node_type="elevator", x=1, y=2, building="NPB"), + node("direct", x=20), + node("detour", x=3, y=4, building="NPB"), + node("end", node_type="room", x=5, y=6, building="NPB"), + ] + edges = [ + {"source": "start", "target": "direct", "weight": 10, "instruction": "Direct."}, + {"source": "direct", "target": "end", "weight": 10, "instruction": "Finish."}, + { + "source": "start", + "target": "detour", + "weight": 2, + "instruction": "Walk ahead.", + }, + { + "source": "detour", + "target": "end", + "weight": 3, + "instruction": "Turn right.", + }, + ] + floors = {"NPB": {"1": {"width_feet": 300, "height_feet": 60}}} + monkeypatch.setattr( + navigation, "_load_map_facts", lambda: facts(nodes, edges, floors) + ) + + outcome = navigation.navigate(select()) + + assert outcome["status"] == "success" + assert outcome["directions"] == ["Walk ahead.", "Turn right."] + assert outcome["traversed_floors"] == [1] + assert outcome["map"] == { + "status": "ready", + "data": { + "coordinates": [ + {"x": 1, "y": 2, "floor": 1}, + {"x": 3, "y": 4, "floor": 1}, + {"x": 5, "y": 6, "floor": 1}, + ], + "bounds": {"width": 300, "height": 60}, + }, + "message": None, + } + + +def test_navigation_uses_node_building_instead_of_parsing_id(monkeypatch): + nodes = [ + node("unstructured-start", building="NPB"), + node("end", node_type="room", building="NPB"), + ] + edges = [ + { + "source": "unstructured-start", + "target": "end", + "weight": 1, + "instruction": "Arrive.", + } + ] + floors = {"NPB": {"1": {"width_feet": 20, "height_feet": 10}}} + monkeypatch.setattr( + navigation, "_load_map_facts", lambda: facts(nodes, edges, floors) + ) + + outcome = navigation.navigate(select("unstructured-start")) + + assert outcome["map"]["data"]["bounds"] == {"width": 20, "height": 10} + + +def test_navigation_handles_same_start_and_destination(monkeypatch): + nodes = [node("room", node_type="room", x=12, y=-3)] + monkeypatch.setattr(navigation, "_load_map_facts", lambda: facts(nodes)) + + outcome = navigation.navigate(select("room", "room")) + + assert outcome["directions"] == ["You are already at your destination."] + assert outcome["map"]["data"]["coordinates"] == [{"x": 12, "y": -3, "floor": 1}] + + +@pytest.mark.parametrize( + ("current_type", "next_type", "instruction"), + [ + ("staircase", "waypoint", "Take the stairs from floor 1 to floor 2."), + ("waypoint", "staircase", "Take the stairs from floor 1 to floor 2."), + ("elevator", "elevator", "Take elevator E1 to floor 2."), + ("waypoint", "elevator", "Move from floor 1 to floor 2."), + ], +) +def test_navigation_describes_floor_transitions( + monkeypatch, current_type, next_type, instruction +): + nodes = [ + node("start", floor=1, node_type=current_type), + node("end", floor=2, node_type=next_type), + ] + edges = [ + { + "source": "start", + "target": "end", + "weight": 1, + "instruction": "Take elevator E1 to floor 2.", + } + ] + floors = { + "BLDG": { + "1": {"width_feet": 100, "height_feet": 80}, + "2": {"width_feet": 100, "height_feet": 80}, + } + } + monkeypatch.setattr( + navigation, "_load_map_facts", lambda: facts(nodes, edges, floors) + ) + + outcome = navigation.navigate(select()) + + assert outcome["directions"] == [instruction] + assert outcome["traversed_floors"] == [1, 2] + assert outcome["map"]["status"] == "unavailable" + assert "spanning multiple floors" in outcome["map"]["message"] + + +def test_navigation_reports_missing_route_without_a_string_or_dict_convention( + monkeypatch, +): + monkeypatch.setattr( + navigation, + "_load_map_facts", + lambda: facts([node("start"), node("end", node_type="room")]), + ) + + outcome = navigation.navigate(select()) + + assert outcome["status"] == "error" + assert outcome["directions"] == [] + assert "No route could be found" in outcome["error"] + + +def test_navigation_turns_data_failures_into_an_outcome(monkeypatch): + monkeypatch.setattr( + navigation, + "_load_map_facts", + lambda: (_ for _ in ()).throw(RuntimeError("data is unavailable")), + ) + + outcome = navigation.navigate(select()) + + assert outcome["status"] == "error" + assert outcome["error"] == "data is unavailable" + assert outcome["options"] == {"entrances": [], "classrooms": []} + + +def test_navigation_reports_missing_floor_data(monkeypatch): + nodes = [node("start"), node("end", node_type="room")] + edges = [ + {"source": "start", "target": "end", "weight": 1, "instruction": "Arrive."} + ] + monkeypatch.setattr(navigation, "_load_map_facts", lambda: facts(nodes, edges, {})) + + outcome = navigation.navigate(select()) + + assert outcome["status"] == "error" + assert outcome["error"] == "No floor data for BLDG floor 1" + + +def test_graph_cache_is_built_lazily_and_can_be_replaced(): + nodes = [node("start"), node("end")] + edge = {"source": "start", "target": "end", "weight": 1, "instruction": "Go."} + + first = navigation._get_graph(nodes, [edge]) + second = navigation._get_graph([], []) + navigation._graph = None + replacement = navigation._get_graph([], []) + + assert isinstance(first, nx.DiGraph) + assert second is first + assert replacement is not first + assert replacement.number_of_nodes() == 0 + + +def test_json_loader_uses_the_module_data_directory(monkeypatch, tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + for filename, value in ( + ("nodes.json", [{"id": "a"}]), + ("edges.json", []), + ("floors.json", {"BLDG": {}}), + ): + (data_dir / filename).write_text(json.dumps(value), encoding="utf-8") + monkeypatch.setattr(navigation, "_DATA_DIR", data_dir) + + assert navigation._load_map_facts() == ([{"id": "a"}], [], {"BLDG": {}}) + + +def test_logic_exports_only_the_deep_navigation_interface(): + assert logic.__all__ == ["navigate"] + assert logic.navigate is navigation.navigate diff --git a/tests/test_routes.py b/tests/test_routes.py index c5ad2d6..350d773 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -5,21 +5,39 @@ import pytest from app import logic, routes -from app.logic import graph_manager - -OPTIONS = ( - [{"id": "NPB_5_E1", "name": "5.E1 elevator"}], - [{"id": "NPB_5_154", "name": "5.154"}], -) +from app.logic import navigation + +OPTIONS = { + "entrances": [{"id": "NPB_5_E1", "name": "5.E1 elevator"}], + "classrooms": [{"id": "NPB_5_154", "name": "5.154"}], +} + + +def make_outcome( + *, + status="idle", + error=None, + directions=None, + map_status="idle", + map_data=None, + map_message=None, +): + return { + "status": status, + "error": error, + "directions": directions or [], + "traversed_floors": [], + "map": {"status": map_status, "data": map_data, "message": map_message}, + "options": OPTIONS, + } @pytest.fixture(autouse=True) -def stub_options(monkeypatch): - monkeypatch.setattr(routes, "get_options", lambda: OPTIONS) +def stub_navigation(monkeypatch): + monkeypatch.setattr(routes, "navigate", lambda selection=None: make_outcome()) def embedded_json(body, variable): - """Read a JSON value exposed by the page's JavaScript contract.""" match = re.search( rf"(?:window\.)?{re.escape(variable)}\s*=\s*(?P.*?);", body, @@ -29,86 +47,39 @@ def embedded_json(body, variable): return json.loads(match.group("value")) -def test_index_renders_form_options_and_map_placeholder(client): +def test_index_passes_an_empty_request_to_navigation_and_renders_options( + client, monkeypatch +): + navigate = Mock(return_value=make_outcome()) + monkeypatch.setattr(routes, "navigate", navigate) + response = client.get("/") assert response.status_code == 200 body = response.get_data(as_text=True) - assert "

Choose Your Options

" in body - assert '' in body - assert '' in body assert '' in body assert 'value="NPB_5_154">5.154' in body assert "Map will appear here after you select directions" in body - assert "/static/css/styles.css" in body - assert "window.routeCoordinates" not in body - - -@pytest.mark.parametrize( - "form_data", - [ - {}, - {"entrance": "NPB_5_E1"}, - {"classroom": "NPB_5_154"}, - {"entrance": "", "classroom": "NPB_5_154"}, - {"entrance": "NPB_5_E1", "classroom": ""}, - ], -) -def test_directions_rejects_missing_form_values(client, monkeypatch, form_data): - get_directions = Mock() - monkeypatch.setattr(routes, "get_directions", get_directions) - - response = client.post("/directions", data=form_data) - - assert response.status_code == 200 - assert "Missing entrance or classroom parameter" in response.get_data(as_text=True) - get_directions.assert_not_called() - - -def test_directions_only_accepts_post(client): - response = client.get("/directions") - - assert response.status_code == 405 - - -def test_live_index_and_multi_floor_post_use_canonical_data(client, monkeypatch): - monkeypatch.setattr(routes, "get_options", logic.get_options) - graph_manager.G = None - - index_response = client.get("/") - assert index_response.status_code == 200 - index_body = index_response.get_data(as_text=True) - assert 'value="NPB_5_E1">5.E1' in index_body - assert 'value="NPB_4_440">4.440' in index_body - - response = client.post( - "/directions", - data={"entrance": "NPB_5_E1", "classroom": "NPB_4_440"}, + assert "window.routeMap" not in body + navigate.assert_called_once_with() + + +def test_directions_passes_the_form_directly_to_navigation(client, monkeypatch): + outcome = make_outcome( + status="success", + directions=["Leave the elevator.", "Turn left."], + map_status="ready", + map_data={ + "coordinates": [ + {"x": 1, "y": 2, "floor": 4}, + {"x": 3.5, "y": -6, "floor": 4}, + ], + "bounds": {"width": 300, "height": 60}, + }, ) - - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert "Take elevator E1 to floor 4." in body - assert "Map preview is unavailable for routes spanning multiple floors." in body - assert "window.routeCoordinates" not in body - - -def test_directions_embeds_route_and_uses_entrance_building_and_first_floor( - client, monkeypatch -): - route = { - "directions": ["Leave the elevator.", "Turn left at the hallway."], - "coordinates": [ - {"x": 1, "y": 2, "floor": 4}, - {"x": 3.5, "y": -6, "floor": 4}, - ], - } - get_directions = Mock(return_value=route) - get_floor_bounds = Mock(return_value={"width": 300, "height": 60}) - monkeypatch.setattr(routes, "get_directions", get_directions) - monkeypatch.setattr(routes, "get_floor_bounds", get_floor_bounds) + navigate = Mock(return_value=outcome) + monkeypatch.setattr(routes, "navigate", navigate) response = client.post( "/directions", @@ -117,118 +88,96 @@ def test_directions_embeds_route_and_uses_entrance_building_and_first_floor( assert response.status_code == 200 body = response.get_data(as_text=True) - assert embedded_json(body, "directionSteps") == route["directions"] - assert embedded_json(body, "routeCoordinates") == route["coordinates"] - assert embedded_json(body, "floorBounds") == {"width": 300, "height": 60} - assert 'id="directions-panel"' in body + assert embedded_json(body, "directionSteps") == outcome["directions"] + assert embedded_json(body, "routeMap") == outcome["map"]["data"] assert "/static/js/directions.js" in body assert "/static/js/map.js" in body - get_directions.assert_called_once_with("NPB_5_E1", "NPB_5_154") - get_floor_bounds.assert_called_once_with("NPB", 4) - + selection = navigate.call_args.args[0] + assert selection["entrance"] == "NPB_5_E1" + assert selection["classroom"] == "NPB_5_154" -def test_directions_displays_no_route_error(client, monkeypatch): - get_directions = Mock(return_value="No path found from start to end.") - get_floor_bounds = Mock() - monkeypatch.setattr(routes, "get_directions", get_directions) - monkeypatch.setattr(routes, "get_floor_bounds", get_floor_bounds) - response = client.post( - "/directions", - data={"entrance": "NPB_5_E1", "classroom": "NPB_5_154"}, - ) - - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert ( - "No route could be found between the selected entrance and destination." in body +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + "Missing entrance or classroom parameter", + "Missing entrance or classroom parameter", + ), + ("data is unavailable", "data is unavailable"), + ( + "No route could be found between the selected entrance and destination.", + "No route could be found", + ), + ], +) +def test_directions_renders_navigation_errors(client, monkeypatch, error, expected): + monkeypatch.setattr( + routes, "navigate", Mock(return_value=make_outcome(status="error", error=error)) ) - assert "window.routeCoordinates" not in body - assert "window.floorBounds" not in body - get_directions.assert_called_once_with("NPB_5_E1", "NPB_5_154") - get_floor_bounds.assert_not_called() - -def test_directions_displays_runtime_error(client, monkeypatch): - get_directions = Mock(side_effect=RuntimeError("data is unavailable")) - get_floor_bounds = Mock() - monkeypatch.setattr(routes, "get_directions", get_directions) - monkeypatch.setattr(routes, "get_floor_bounds", get_floor_bounds) - - response = client.post( - "/directions", - data={"entrance": "NPB_5_E1", "classroom": "NPB_5_154"}, - ) + response = client.post("/directions", data={}) assert response.status_code == 200 - assert "data is unavailable" in response.get_data(as_text=True) - get_floor_bounds.assert_not_called() + assert expected in response.get_data(as_text=True) -def test_directions_displays_floor_bounds_runtime_error(client, monkeypatch): - route = { - "directions": ["Leave the elevator."], - "coordinates": [{"x": 1, "y": 2, "floor": 4}], - } - monkeypatch.setattr(routes, "get_directions", Mock(return_value=route)) +def test_directions_renders_map_unavailability_from_outcome(client, monkeypatch): + message = "Map preview is unavailable for routes spanning multiple floors." monkeypatch.setattr( routes, - "get_floor_bounds", - Mock(side_effect=RuntimeError("floor data is unavailable")), + "navigate", + Mock( + return_value=make_outcome( + status="success", + directions=["Take the elevator."], + map_status="unavailable", + map_message=message, + ) + ), ) - response = client.post( - "/directions", - data={"entrance": "NPB_5_E1", "classroom": "NPB_5_154"}, - ) + response = client.post("/directions", data={}) + body = response.get_data(as_text=True) - assert response.status_code == 200 - assert "floor data is unavailable" in response.get_data(as_text=True) + assert message in body + assert "window.routeMap" not in body -def test_directions_does_not_request_floor_bounds_for_empty_coordinates( - client, monkeypatch -): - route = { - "directions": ["The route has no mappable coordinates."], - "coordinates": [], - } - get_floor_bounds = Mock() - monkeypatch.setattr(routes, "get_directions", Mock(return_value=route)) - monkeypatch.setattr(routes, "get_floor_bounds", get_floor_bounds) +def test_live_navigation_uses_canonical_data(client, monkeypatch): + monkeypatch.setattr(routes, "navigate", logic.navigate) + navigation._graph = None response = client.post( "/directions", - data={"entrance": "NPB_5_E1", "classroom": "NPB_5_154"}, + data={"entrance": "NPB_5_E1", "classroom": "NPB_4_440"}, ) assert response.status_code == 200 body = response.get_data(as_text=True) - assert embedded_json(body, "directionSteps") == route["directions"] - assert "window.routeCoordinates" not in body - assert "window.floorBounds" not in body - assert "Map will appear here after you select directions" in body - get_floor_bounds.assert_not_called() + assert "Take elevator E1 to floor 4." in body + assert "Map preview is unavailable for routes spanning multiple floors." in body + assert "window.routeMap" not in body -def test_api_test_returns_json_from_pathfinder(client, monkeypatch): - expected = {"directions": ["Test route"], "coordinates": []} - get_directions = Mock(return_value=expected) - monkeypatch.setattr(routes, "get_directions", get_directions) +def test_directions_only_accepts_post(client): + assert client.get("/directions").status_code == 405 + + +def test_api_test_returns_the_navigation_outcome(client, monkeypatch): + expected = make_outcome(status="success", directions=["Test route"]) + navigate = Mock(return_value=expected) + monkeypatch.setattr(routes, "navigate", navigate) response = client.get("/api/test") assert response.status_code == 200 - assert response.is_json - assert response.mimetype == "application/json" assert response.get_json() == expected - get_directions.assert_called_once_with("NPB_5_E1", "NPB_5_154") + navigate.assert_called_once_with({"entrance": "NPB_5_E1", "classroom": "NPB_5_154"}) def test_unknown_url_returns_not_found(client): - response = client.get("/does-not-exist") - - assert response.status_code == 404 + assert client.get("/does-not-exist").status_code == 404 @pytest.mark.parametrize( diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 50a68ad..c42153b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -8,12 +8,12 @@ def test_app_factory_registers_blueprint_without_loading_graph(monkeypatch): """Creating the Flask app must not read or build the graph.""" from app import create_app - from app.logic import graph_manager + from app.logic import navigation - read_json_files = Mock(side_effect=AssertionError("graph data loaded too early")) - build_graph = Mock(side_effect=AssertionError("graph built too early")) - monkeypatch.setattr(graph_manager, "read_json_files", read_json_files) - monkeypatch.setattr(graph_manager, "build_graph", build_graph) + load_map_facts = Mock(side_effect=AssertionError("map data loaded too early")) + get_graph = Mock(side_effect=AssertionError("graph built too early")) + monkeypatch.setattr(navigation, "_load_map_facts", load_map_facts) + monkeypatch.setattr(navigation, "_get_graph", get_graph) app = create_app() @@ -25,8 +25,8 @@ def test_app_factory_registers_blueprint_without_loading_graph(monkeypatch): assert rules["/"].endpoint == "main.index" assert rules["/directions"].endpoint == "main.directions" assert rules["/api/test"].endpoint == "main.test" - read_json_files.assert_not_called() - build_graph.assert_not_called() + load_map_facts.assert_not_called() + get_graph.assert_not_called() @pytest.mark.parametrize( diff --git a/uv.lock b/uv.lock index b1657f9..7969644 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,6 @@ source = { virtual = "." } dependencies = [ { name = "flask" }, { name = "gunicorn" }, - { name = "matplotlib" }, { name = "networkx" }, ] @@ -33,7 +32,6 @@ dev = [ requires-dist = [ { name = "flask", specifier = ">=3.1.2" }, { name = "gunicorn", specifier = ">=25.3.0" }, - { name = "matplotlib", specifier = ">=3.10.8" }, { name = "networkx", specifier = ">=3.6.1" }, ] @@ -65,39 +63,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, -] - [[package]] name = "coverage" version = "7.15.4" @@ -167,15 +132,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -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 = "flask" version = "3.1.2" @@ -193,31 +149,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, ] -[[package]] -name = "fonttools" -version = "4.62.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, -] - [[package]] name = "gunicorn" version = "25.3.0" @@ -260,44 +191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "kiwisolver" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -328,39 +221,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "matplotlib" -version = "3.10.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, -] - [[package]] name = "networkx" version = "3.6.1" @@ -370,35 +230,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "numpy" -version = "2.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, - { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, - { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, - { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, - { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, - { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, - { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, - { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, - { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, - { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, - { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, -] - [[package]] name = "packaging" version = "26.0" @@ -408,39 +239,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -459,15 +257,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - [[package]] name = "pytest" version = "9.1.1" @@ -498,18 +287,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "ruff" version = "0.16.3" @@ -535,15 +312,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -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 = "werkzeug" version = "3.1.5"