networkforge 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
networkforge/cli.py ADDED
@@ -0,0 +1,681 @@
1
+ """
2
+ Command-line interface: `networkforge build | check | traffic | presets | info`.
3
+
4
+ networkforge build --extent area.gpkg --custom plan.gpkg --preset primary_road \\
5
+ --tag maxspeed="40 mph" --out network.osm.pbf --gpkg after.gpkg \\
6
+ --baseline-gpkg before.gpkg
7
+ networkforge traffic --pbf network.osm.pbf --settings traffic.toml --out network.tar
8
+
9
+ Output streams: stdout carries the result - readable text by default,
10
+ or with --json one JSON object per line, for programs driving the CLI.
11
+ stderr always carries human-readable progress, warnings and errors.
12
+
13
+ JSON events (a stable interface: other tools depend on these shapes):
14
+
15
+ {"event": "progress", "step": 2, "total": 13, "message": "Downloading OSM network"}
16
+ {"event": "warning", "message": "...", "features": [3, 7]} # or fields, rows, gaps
17
+ {"event": "done", "outputs": {"osm": "network.osm.pbf"}, "nodes": 131459, ...,
18
+ "custom_edges": 12, "modified_edges": 3, "removed_edges": 2}
19
+ {"event": "done", "outputs": {"tar": "network.tar"}, "streets": 840,
20
+ "segments": 1702, "by_road": {"primary": 310, ...}, "bands": ["Morning peak"],
21
+ "streets_not_found": [], "single_streets": 3, "single_segments": 8,
22
+ "unmatched": [], "table_rows": 40, "unmatched_rows": []} # traffic
23
+ (edge counts are streets, one per GeoPackage row: a two-way street is
24
+ one edge, not one each way)
25
+ {"event": "error", "type": "InvalidTagsError", "message": "...", "guide": "...",
26
+ "issues": [{"feature": 3, "message": "maxspeed='fast' is not a valid OSM speed"}],
27
+ "problems": ["feature 3: maxspeed='fast' is not a valid OSM speed"]}
28
+
29
+ Features are identified by --id-field (e.g. a GeoPackage's fid), or by
30
+ row number when it isn't given.
31
+
32
+ Exit codes: 0 success, 1 unexpected error, 2 bad command-line usage,
33
+ 3 unusable input (InputError and subclasses), 4 OSM download failed,
34
+ 5 built network failed a structural check.
35
+ """
36
+
37
+ import argparse
38
+ import json
39
+ import logging
40
+ import os
41
+ import sys
42
+ from collections import Counter
43
+ from collections.abc import Sequence
44
+ from importlib.metadata import version
45
+ from pathlib import Path
46
+
47
+ import geopandas as gpd
48
+ import numpy as np
49
+ from shapely.geometry import box
50
+
51
+ from .edits import check_edit_tags, split_edits, take_edit_ids
52
+ from .errors import (
53
+ InputError,
54
+ InvalidTagsError,
55
+ NetworkForgeError,
56
+ NetworkIntegrityError,
57
+ OSMDownloadError,
58
+ feature_id,
59
+ )
60
+ from .export import (
61
+ GPKG_ANALYSIS_COLUMNS,
62
+ RESTRICTION_COLUMNS,
63
+ prepare_osm_data,
64
+ reverse_copies,
65
+ write_gpkg,
66
+ write_osm,
67
+ )
68
+ from .inputs import (
69
+ MAX_OVERPASS_AREA_KM2,
70
+ check_feature_ids,
71
+ clean_custom_data,
72
+ drop_reserved_columns,
73
+ )
74
+ from .modes import MODES, usable_modes
75
+ from .network import JOIN_AT, build_network
76
+ from .osm import NETWORK_TYPES
77
+ from .points import check_point_tags, split_points
78
+ from .presets import PRESETS, preset_tags
79
+ from .tags import (
80
+ EDIT_ID_COLUMN,
81
+ EDIT_ID_COLUMNS,
82
+ FERRY_ROUTES,
83
+ MODIFIED_COLUMN,
84
+ NODE_CHANGES_ATTR,
85
+ REMOVE_COLUMN,
86
+ REMOVE_TAGS_COLUMN,
87
+ REMOVED_ATTR,
88
+ TURNS_ATTR,
89
+ )
90
+ from .traffic import (
91
+ ALL_ROADS,
92
+ BUCKET_MINUTES,
93
+ DIRECTIONS,
94
+ MAX_FACTOR,
95
+ MAX_SPEED_KPH,
96
+ MIN_FACTOR,
97
+ MIN_SPEED_KPH,
98
+ STREET_FIELDS,
99
+ )
100
+ from .turns import RESTRICTION_KEYS, TURN_VALUES, check_turn_tags, split_turns
101
+ from .validation import (
102
+ ACCESS_KEYS,
103
+ ACCESS_VALUES,
104
+ DURATION_PATTERN,
105
+ INCLINE_PATTERN,
106
+ KNOWN_HIGHWAYS,
107
+ KNOWN_TAG_KEYS,
108
+ KNOWN_VALUES,
109
+ LIMIT_KEYS,
110
+ LIMIT_PATTERN,
111
+ MAXSPEED_PATTERN,
112
+ ONEWAY_VALUES,
113
+ check_custom_tags,
114
+ resolve_custom_tags,
115
+ warn_about_unwritten_tags,
116
+ )
117
+
118
+ EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_INPUT, EXIT_DOWNLOAD, EXIT_INTEGRITY = range(6)
119
+
120
+ log = logging.getLogger("networkforge.cli")
121
+
122
+
123
+ def main(argv: Sequence[str] | None = None) -> int:
124
+ parser = _parser()
125
+ args = parser.parse_args(_join_negative_bbox(sys.argv[1:] if argv is None else argv))
126
+ if args.command is cmd_build:
127
+ _check_build_arguments(parser, args)
128
+ emit = _emitter(args.json)
129
+ _configure_logging(args.verbose, args.quiet, emit if args.json else None)
130
+
131
+ try:
132
+ return args.command(args, emit)
133
+ except BrokenPipeError:
134
+ # The reader went away (e.g. `| head`): stop quietly, as the
135
+ # Python docs recommend, instead of a traceback on exit.
136
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
137
+ return EXIT_ERROR
138
+ except NetworkForgeError as exc:
139
+ emit_error(emit, exc)
140
+ return _exit_code(exc)
141
+ except Exception as exc: # noqa: BLE001 - report anything, don't dump a traceback
142
+ log.debug("Unexpected error", exc_info=True)
143
+ emit_error(emit, exc)
144
+ return EXIT_ERROR
145
+
146
+
147
+ # ---------------------------------------------------------------- commands
148
+
149
+ def cmd_build(args, emit) -> int:
150
+ if not (args.out or args.gpkg or args.baseline_out or args.baseline_gpkg):
151
+ raise InputError("Nothing to write: give --out, --gpkg, --baseline-out or --baseline-gpkg.")
152
+
153
+ _check_output_paths(args)
154
+ bbox = None if args.no_osm else _read_extent(args)
155
+ custom = _read_custom(args)
156
+ baseline = bool(args.baseline_out or args.baseline_gpkg)
157
+
158
+ def progress(step: int, total: int, message: str) -> None:
159
+ emit({"event": "progress", "step": step, "total": total, "message": message})
160
+
161
+ result = build_network(
162
+ bbox,
163
+ custom,
164
+ network_tags=_tags(args.tag),
165
+ network_type=args.network_type,
166
+ return_source_osm=baseline,
167
+ snap_tolerance=args.snap_tolerance,
168
+ strict=not args.no_strict,
169
+ preset=args.preset,
170
+ overwrite_tags=args.overwrite_tags,
171
+ osm_source=args.osm_source,
172
+ standalone=args.no_osm,
173
+ join_at=args.join_at,
174
+ progress=progress,
175
+ )
176
+ nodes, edges = result[:2]
177
+
178
+ outputs = {}
179
+ # Made once for each network: the GeoPackage's restrictions layer is
180
+ # drawn from what the OSM file holds.
181
+ after = prepare_osm_data(nodes, edges) if args.out else None
182
+ before = prepare_osm_data(result[2], result[3]) if args.baseline_out else None
183
+ if args.out:
184
+ write_osm(nodes, edges, args.out, data=after)
185
+ outputs["osm"] = str(args.out)
186
+ if args.baseline_out:
187
+ write_osm(result[2], result[3], args.baseline_out, data=before)
188
+ outputs["baseline_osm"] = str(args.baseline_out)
189
+ if args.gpkg:
190
+ write_gpkg(nodes, edges, args.gpkg, osm_data=after)
191
+ outputs["gpkg"] = str(args.gpkg)
192
+ if args.baseline_gpkg:
193
+ write_gpkg(result[2], result[3], args.baseline_gpkg, osm_data=before)
194
+ outputs["baseline_gpkg"] = str(args.baseline_gpkg)
195
+
196
+ # One per street, as in the GeoPackage: OSMnx holds a two-way street
197
+ # as two edges, one each way.
198
+ streets = edges[~reverse_copies(edges)]
199
+ custom_edges = int((streets["custom"] == "yes").sum()) if "custom" in streets else 0
200
+ modified_edges = (int((streets[MODIFIED_COLUMN] == "yes").sum())
201
+ if MODIFIED_COLUMN in streets else 0)
202
+ removed_edges = int(edges.attrs.get(REMOVED_ATTR, 0))
203
+ turn_restrictions = len(edges.attrs.get(TURNS_ATTR) or [])
204
+ tagged_nodes = len(edges.attrs.get(NODE_CHANGES_ATTR) or {})
205
+ emit({"event": "done", "outputs": outputs, "nodes": len(nodes), "edges": len(streets),
206
+ "custom_edges": custom_edges, "modified_edges": modified_edges,
207
+ "removed_edges": removed_edges, "turn_restrictions": turn_restrictions,
208
+ "tagged_nodes": tagged_nodes},
209
+ text=f"Done: {len(nodes):,} nodes, {len(streets):,} edges ({custom_edges:,} custom, "
210
+ f"{modified_edges:,} changed, {removed_edges:,} removed). "
211
+ f"Wrote {', '.join(outputs.values())}")
212
+ return EXIT_OK
213
+
214
+
215
+ def cmd_check(args, emit) -> int:
216
+ custom = _read_custom(args)
217
+ if args.extent or args.bbox:
218
+ custom = clean_custom_data(custom, _read_extent(args), strict=True)
219
+ else:
220
+ check_feature_ids(custom)
221
+ custom = take_edit_ids(custom)
222
+ custom = drop_reserved_columns(custom, quiet=EDIT_ID_COLUMN in custom.columns)
223
+
224
+ # Changes to existing streets and turn restrictions are checked for
225
+ # valid values only: where they apply is known once the network is
226
+ # built.
227
+ custom, points = split_points(custom)
228
+ check_point_tags(points, strict=True)
229
+ custom, turns = split_turns(custom)
230
+ check_turn_tags(turns, strict=True)
231
+ warn_about_unwritten_tags(custom)
232
+ custom, edits = split_edits(custom)
233
+ check_edit_tags(edits, strict=True)
234
+
235
+ blanket = {**(preset_tags(args.preset) if args.preset else {}), **_tags(args.tag)}
236
+ if not custom.empty:
237
+ custom = resolve_custom_tags(custom, blanket, overwrite=args.overwrite_tags)
238
+ check_custom_tags(custom, args.network_type, strict=True)
239
+
240
+ tag_columns = [c for c in custom.columns if c != custom.geometry.name]
241
+ modes = Counter(
242
+ ", ".join(usable_modes({c: row[c] for c in tag_columns if row[c] is not None}))
243
+ or "nothing"
244
+ for _, row in custom.iterrows()
245
+ )
246
+ summary = "; ".join(f"{count} usable by {m}" for m, count in modes.most_common())
247
+ if len(edits):
248
+ summary = "; ".join(filter(None, [summary, f"{len(edits)} change existing streets"]))
249
+ if len(turns):
250
+ summary = "; ".join(filter(None, [summary, f"{len(turns)} turn restriction(s)"]))
251
+ if len(points):
252
+ summary = "; ".join(filter(None, [summary, f"{len(points)} point(s)"]))
253
+ features = len(custom) + len(edits) + len(turns) + len(points)
254
+ emit({"event": "done", "features": features, "modes": dict(modes),
255
+ "edits": len(edits), "turn_restrictions": len(turns), "points": len(points)},
256
+ text=f"OK: {features} feature(s) - {summary}")
257
+ return EXIT_OK
258
+
259
+
260
+ def cmd_traffic(args, emit) -> int:
261
+ from .traffic import add_traffic, read_traffic_settings, speed_table, street_bands
262
+
263
+ if not (args.settings or args.streets or args.speed_table):
264
+ raise InputError("No traffic to add: give --settings (road-class bands), --streets "
265
+ "(single streets), --speed-table (speeds by OSM way id), or several.",
266
+ guide="traffic.md")
267
+ bands = read_traffic_settings(args.settings) if args.settings else []
268
+ streets = (street_bands(_read_with_ids(args.streets, args.streets_layer, args.id_field,
269
+ "single streets"))
270
+ if args.streets else [])
271
+ if args.speed_table:
272
+ streets += speed_table(_read_table(args.speed_table, args.speed_table_layer,
273
+ args.speed_table_id_field))
274
+
275
+ def progress(step: int, total: int, message: str) -> None:
276
+ emit({"event": "progress", "step": step, "total": total, "message": message})
277
+
278
+ result = add_traffic(args.pbf, bands, args.out, streets=streets, progress=progress)
279
+ roads = ", ".join(f"{count:,} {road}" for road, count in result.by_road.items()) or "none"
280
+ emit({"event": "done", "outputs": {"tar": str(result.output)}, "streets": result.ways,
281
+ "segments": result.segments, "by_road": result.by_road, "bands": result.bands,
282
+ "streets_not_found": result.streets_not_found,
283
+ "single_streets": result.single_streets, "single_segments": result.single_segments,
284
+ "unmatched": [feature_id(f) for f in result.unmatched],
285
+ "table_rows": result.table_rows,
286
+ "unmatched_rows": [feature_id(r) for r in result.unmatched_rows]},
287
+ text=f"Done: traffic on {result.ways:,} streets ({result.segments:,} road segments). "
288
+ f"Road classes: {roads}; single streets: {result.single_streets:,}; speed "
289
+ f"table rows: {result.table_rows:,} ({result.single_segments:,} segments for "
290
+ f"the two). Wrote {result.output}")
291
+ return EXIT_OK
292
+
293
+
294
+ def cmd_info(args, emit) -> int:
295
+ """Everything a front end needs to build its UI from the engine."""
296
+ info = {
297
+ "event": "info",
298
+ "version": version("networkforge"),
299
+ "presets": {name: {"tags": tags, "modes": usable_modes(tags)}
300
+ for name, tags in PRESETS.items()},
301
+ "network_types": list(NETWORK_TYPES),
302
+ "modes": list(MODES),
303
+ "max_overpass_area_km2": MAX_OVERPASS_AREA_KM2,
304
+ "osm_formats": [".osm", ".osm.pbf", ".pbf", ".osm.gz", ".osm.bz2"],
305
+ "gpkg_edge_columns": list(GPKG_ANALYSIS_COLUMNS),
306
+ "gpkg_layers": ["nodes", "edges", "restrictions"],
307
+ "gpkg_restriction_columns": list(RESTRICTION_COLUMNS),
308
+ "edit_id_fields": list(EDIT_ID_COLUMNS),
309
+ "remove_field": REMOVE_COLUMN,
310
+ "remove_tags_field": REMOVE_TAGS_COLUMN,
311
+ "turn_restriction_fields": list(RESTRICTION_KEYS),
312
+ "standalone": True,
313
+ "join_at": list(JOIN_AT),
314
+ "traffic": {"days": ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
315
+ "minutes_step": BUCKET_MINUTES, "factor_range": [MIN_FACTOR, MAX_FACTOR],
316
+ "roads": sorted({r.removesuffix("_link") for r in KNOWN_HIGHWAYS}),
317
+ "all_roads": ALL_ROADS, "output": ".tar",
318
+ "street_fields": list(STREET_FIELDS), "directions": list(DIRECTIONS),
319
+ "street_id_fields": list(EDIT_ID_COLUMNS),
320
+ "speed_kph_range": [MIN_SPEED_KPH, MAX_SPEED_KPH],
321
+ "speed_table": True},
322
+ "tag_keys": sorted(KNOWN_TAG_KEYS),
323
+ "tag_values": {
324
+ "restriction": sorted(TURN_VALUES),
325
+ "highway": sorted(KNOWN_HIGHWAYS),
326
+ "route": list(FERRY_ROUTES),
327
+ **{key: sorted(values) for key, values in KNOWN_VALUES.items()},
328
+ "oneway": sorted(ONEWAY_VALUES),
329
+ **{key: sorted(ACCESS_VALUES) for key in ACCESS_KEYS},
330
+ },
331
+ "tag_patterns": {"maxspeed": MAXSPEED_PATTERN.pattern, "lanes": r"^[1-9]\d*$",
332
+ "layer": r"^-?\d+$", "duration": DURATION_PATTERN.pattern,
333
+ "incline": INCLINE_PATTERN.pattern,
334
+ **{key: LIMIT_PATTERN.pattern for key in LIMIT_KEYS}},
335
+ }
336
+ emit(info, text=(
337
+ f"networkforge {info['version']}\n"
338
+ f"presets: {', '.join(PRESETS)}\n"
339
+ f"network types: {', '.join(NETWORK_TYPES)}\n"
340
+ f"Overpass area limit: {MAX_OVERPASS_AREA_KM2:,} km2 (larger areas need --osm-source)\n"
341
+ f"highway values: {', '.join(sorted(KNOWN_HIGHWAYS))}"
342
+ ))
343
+ return EXIT_OK
344
+
345
+
346
+ def cmd_presets(args, emit) -> int:
347
+ for name, tags in PRESETS.items():
348
+ modes = usable_modes(tags)
349
+ emit({"event": "preset", "name": name, "tags": tags, "modes": modes},
350
+ text=f"{name:20s} {', '.join(f'{k}={v}' for k, v in tags.items()):55s} "
351
+ f"{', '.join(modes)}")
352
+ return EXIT_OK
353
+
354
+
355
+ # ---------------------------------------------------------------- arguments
356
+
357
+ def _parser() -> argparse.ArgumentParser:
358
+ parser = argparse.ArgumentParser(
359
+ prog="networkforge",
360
+ description="Build and edit routable street networks: add your own roads, cycleways "
361
+ "and paths to OpenStreetMap, change or remove existing streets, or "
362
+ "build a network from your own lines alone.",
363
+ epilog="Network rules: docs/network-rules.md. Large areas: docs/osm-data.md.",
364
+ )
365
+ parser.add_argument("--version", action="version",
366
+ version=f"%(prog)s {version('networkforge')}")
367
+ common = argparse.ArgumentParser(add_help=False)
368
+ common.add_argument("--json", action="store_true",
369
+ help="write JSON lines to stdout (for programs) instead of text")
370
+ common.add_argument("-v", "--verbose", action="store_true", help="show debug detail")
371
+ common.add_argument("-q", "--quiet", action="store_true", help="only show warnings and errors")
372
+ commands = parser.add_subparsers(title="commands", required=True)
373
+
374
+ build = commands.add_parser("build", parents=[common],
375
+ help="build the combined network and write it out",
376
+ description="Build the OSM + custom network, or with --no-osm "
377
+ "a network of the custom lines alone.")
378
+ _add_extent(build, required=False)
379
+ _add_custom(build)
380
+ build.add_argument("--no-osm", action="store_true",
381
+ help="build a network from the custom lines alone, without "
382
+ "OpenStreetMap: no area, download or before network")
383
+ build.add_argument("--join-at", choices=JOIN_AT, default="crossings",
384
+ help="with --no-osm, where lines join each other: wherever they "
385
+ "cross (default), or only at vertices they share")
386
+ build.add_argument("--osm-source", type=Path, metavar="FILE",
387
+ help="local OSM extract (.osm.pbf etc.) instead of the Overpass API; "
388
+ "required for areas over 1,000 km2")
389
+ build.add_argument("--snap-tolerance", type=float, default=1.0, metavar="METRES",
390
+ help="how close custom lines must come to join the network (default 1)")
391
+ build.add_argument("--no-strict", action="store_true",
392
+ help="warn and drop unusable features instead of failing")
393
+ build.add_argument("--out", type=Path, metavar="FILE",
394
+ help="OSM output: .osm, .osm.pbf, .osm.gz or .osm.bz2")
395
+ build.add_argument("--baseline-out", type=Path, metavar="FILE",
396
+ help="also write the untouched OSM network, for before/after comparisons")
397
+ build.add_argument("--baseline-gpkg", type=Path, metavar="FILE",
398
+ help="also write the untouched OSM network as a GeoPackage for QGIS "
399
+ "(same layers and columns as --gpkg), for before/after")
400
+ build.add_argument("--gpkg", type=Path, metavar="FILE",
401
+ help="GeoPackage for QGIS: 'nodes' and 'edges' layers, edges with "
402
+ "network-analysis columns (car/bike/walk, speed, times, direction)")
403
+ build.set_defaults(command=cmd_build)
404
+
405
+ check = commands.add_parser("check", parents=[common],
406
+ help="check custom data and tags without downloading anything",
407
+ description="Validate custom features and their tags.")
408
+ _add_extent(check, required=False)
409
+ _add_custom(check)
410
+ check.set_defaults(command=cmd_check)
411
+
412
+ traffic = commands.add_parser(
413
+ "traffic", parents=[common],
414
+ help="add time-of-day traffic to a network for Valhalla (writes a .tar)",
415
+ description="Put time bands of slower speeds (by road class) on a network built by "
416
+ "NetworkForge, as Valhalla's predicted traffic, and write Valhalla's graph "
417
+ "as a .tar file (the Network Analyst plugin's 'From Tar'). Routes see the "
418
+ "traffic only when given a departure or arrival time. Needs pyvalhalla: "
419
+ 'pip install "networkforge[traffic]".',
420
+ epilog="Settings: docs/traffic.md.")
421
+ traffic.add_argument("--pbf", type=Path, required=True, metavar="FILE",
422
+ help="the network: an OSM file NetworkForge wrote (.osm.pbf)")
423
+ traffic.add_argument("--settings", type=Path, metavar="FILE",
424
+ help="road-class bands (.toml or .json; see docs/traffic.md)")
425
+ traffic.add_argument("--streets", type=Path, metavar="FILE",
426
+ help="single streets: a line layer with days, time, factor or "
427
+ "speed_kph (and direction, osm_id / osmid); they override the "
428
+ "road classes")
429
+ traffic.add_argument("--streets-layer", metavar="NAME", help="layer in the streets file")
430
+ traffic.add_argument("--speed-table", type=Path, metavar="FILE",
431
+ help="speeds by OSM way id: a table (CSV, or a GeoPackage/... layer) "
432
+ "with osm_id or osmid, days, time, speed_kph or factor (and "
433
+ "direction); they override the road classes")
434
+ traffic.add_argument("--speed-table-layer", metavar="NAME",
435
+ help="layer in the speed table file")
436
+ traffic.add_argument("--speed-table-id-field", metavar="NAME",
437
+ help="attribute that names speed table rows in warnings and errors; "
438
+ "default: row number (1 = the first row after the header)")
439
+ traffic.add_argument("--id-field", metavar="NAME",
440
+ help="attribute that identifies street features in warnings and "
441
+ "errors (e.g. fid); default: row number")
442
+ traffic.add_argument("--out", type=Path, required=True, metavar="FILE",
443
+ help="Valhalla graph with the traffic (.tar)")
444
+ traffic.set_defaults(command=cmd_traffic)
445
+
446
+ presets = commands.add_parser("presets", parents=[common], help="list the tag presets")
447
+ presets.set_defaults(command=cmd_presets)
448
+
449
+ info = commands.add_parser(
450
+ "info", parents=[common],
451
+ help="version, presets, valid tag values and limits (for front ends)")
452
+ info.set_defaults(command=cmd_info)
453
+
454
+ return parser
455
+
456
+
457
+ def _add_extent(parser, required: bool) -> None:
458
+ group = parser.add_mutually_exclusive_group(required=required)
459
+ group.add_argument("--extent", type=Path, metavar="FILE",
460
+ help="vector file whose extent is the area (any CRS)")
461
+ group.add_argument("--bbox", metavar="W,S,E,N",
462
+ help="area as WGS84 longitude/latitude: west,south,east,north")
463
+ parser.add_argument("--extent-layer", metavar="NAME", help="layer in the extent file")
464
+
465
+
466
+ def _add_custom(parser) -> None:
467
+ parser.add_argument("--custom", type=Path, required=True, metavar="FILE",
468
+ help="custom lines (GeoPackage, GeoJSON, ...)")
469
+ parser.add_argument("--custom-layer", metavar="NAME", help="layer in the custom file")
470
+ parser.add_argument("--id-field", metavar="NAME",
471
+ help="attribute that identifies features in warnings and errors "
472
+ "(e.g. fid); default: row number")
473
+ parser.add_argument("--preset", choices=list(PRESETS), metavar="NAME",
474
+ help="blanket tag preset (list them with: networkforge presets)")
475
+ parser.add_argument("--tag", action="append", default=[], metavar="KEY=VALUE",
476
+ help="blanket tag, repeatable; beats the preset")
477
+ parser.add_argument("--overwrite-tags", action="store_true",
478
+ help="blanket tags replace features' own values instead of filling gaps")
479
+ parser.add_argument("--network-type", choices=NETWORK_TYPES, default="all",
480
+ help="OSM network to download (default all: every mode)")
481
+
482
+
483
+ def _check_build_arguments(parser, args) -> None:
484
+ """build needs an area unless --no-osm, which in turn has no OSM options."""
485
+ if not args.no_osm:
486
+ if not (args.extent or args.bbox):
487
+ parser.error("one of the arguments --extent --bbox is required (or --no-osm)")
488
+ if args.join_at != "crossings":
489
+ parser.error("--join-at is for --no-osm networks")
490
+ return
491
+ for given, flag in ((args.extent or args.bbox, "--extent / --bbox"),
492
+ (args.osm_source, "--osm-source"),
493
+ (args.baseline_out, "--baseline-out"),
494
+ (args.baseline_gpkg, "--baseline-gpkg")):
495
+ if given:
496
+ parser.error(f"{flag} can't be used with --no-osm (there is no OpenStreetMap "
497
+ "network or before network)")
498
+
499
+
500
+ def _check_output_paths(args) -> None:
501
+ """
502
+ Before the build (and the download): no output may replace an input
503
+ or another output, and each needs a folder that exists.
504
+ """
505
+ inputs = {"--custom": args.custom, "--extent": args.extent, "--osm-source": args.osm_source}
506
+ outputs = {"--out": args.out, "--baseline-out": args.baseline_out,
507
+ "--gpkg": args.gpkg, "--baseline-gpkg": args.baseline_gpkg}
508
+ seen = {Path(path).resolve(): flag for flag, path in inputs.items() if path}
509
+ for flag, path in outputs.items():
510
+ if not path:
511
+ continue
512
+ resolved = Path(path).resolve()
513
+ if resolved in seen:
514
+ raise InputError(f"{flag} {path} is the same file as {seen[resolved]}: it would "
515
+ "be overwritten. Give the output a name of its own.")
516
+ seen[resolved] = flag
517
+ if not resolved.parent.is_dir():
518
+ raise InputError(f"The folder for {flag} doesn't exist: {resolved.parent}")
519
+
520
+
521
+ # ---------------------------------------------------------------- helpers
522
+
523
+ def _join_negative_bbox(argv: Sequence[str]) -> list[str]:
524
+ """
525
+ `--bbox -4.6,54.1,...` would make argparse read the western
526
+ longitude as an option; turn it into `--bbox=-4.6,54.1,...`.
527
+ """
528
+ argv = list(argv)
529
+ for i, arg in enumerate(argv[:-1]):
530
+ if arg == "--bbox" and argv[i + 1].startswith("-"):
531
+ argv[i : i + 2] = [f"--bbox={argv[i + 1]}"]
532
+ break
533
+ return argv
534
+
535
+
536
+ def _tags(pairs: list[str]) -> dict[str, str]:
537
+ tags = {}
538
+ for pair in pairs:
539
+ key, sep, value = pair.partition("=")
540
+ if not sep or not key.strip():
541
+ raise InputError(f"--tag must look like KEY=VALUE, got {pair!r}")
542
+ tags[key.strip()] = value.strip()
543
+ return tags
544
+
545
+
546
+ def _read_layer(path: Path, layer: str | None, label: str, **kwargs) -> gpd.GeoDataFrame:
547
+ if not path.is_file():
548
+ raise InputError(f"The {label} file doesn't exist: {path}")
549
+ try:
550
+ return gpd.read_file(path, layer=layer, **kwargs)
551
+ except Exception as exc: # noqa: BLE001 - GDAL raises many types
552
+ raise InputError(f"Can't read the {label} from {path}: {exc}") from exc
553
+
554
+
555
+ def _read_custom(args) -> gpd.GeoDataFrame:
556
+ """The custom layer, indexed by --id-field so messages name those ids."""
557
+ return _read_with_ids(args.custom, args.custom_layer, args.id_field, "custom data")
558
+
559
+
560
+ def _read_with_ids(path: Path, layer: str | None, field: str | None,
561
+ label: str) -> gpd.GeoDataFrame:
562
+ """A layer indexed by the attribute `field` (--id-field) so messages name those ids."""
563
+ data = _read_layer(path, layer, label)
564
+ if not field:
565
+ return data
566
+ if field in data.columns:
567
+ return data.set_index(field)
568
+ # A GeoPackage's fid isn't a column unless asked for.
569
+ with_fid = _read_layer(path, layer, label, fid_as_index=True)
570
+ if field.lower() == "fid":
571
+ return with_fid.rename_axis(field)
572
+ attributes = ", ".join(map(str, data.columns.drop(data.geometry.name)))
573
+ raise InputError(f"--id-field {field!r} isn't an attribute of the {label}. "
574
+ f"Attributes: {attributes}")
575
+
576
+
577
+ def _read_table(path: Path, layer: str | None, field: str | None):
578
+ """A table (CSV, or any layer, with or without geometry), indexed by `field` or row number."""
579
+ if not path.is_file():
580
+ raise InputError(f"The speed table file doesn't exist: {path}")
581
+ try:
582
+ if path.suffix.lower() == ".csv":
583
+ import pandas as pd
584
+ table = pd.read_csv(path, dtype=str, keep_default_na=False)
585
+ else:
586
+ table = gpd.read_file(path, layer=layer)
587
+ except Exception as exc: # noqa: BLE001 - pandas and GDAL raise many types
588
+ raise InputError(f"Can't read the speed table from {path}: {exc}") from exc
589
+ if field:
590
+ if field not in table.columns:
591
+ raise InputError(f"--speed-table-id-field {field!r} isn't a column of the speed "
592
+ f"table. Columns: {', '.join(map(str, table.columns))}")
593
+ return table.set_index(field)
594
+ table.index = range(1, len(table) + 1)
595
+ return table
596
+
597
+
598
+ def _read_extent(args) -> gpd.GeoDataFrame:
599
+ if args.bbox:
600
+ try:
601
+ west, south, east, north = (float(v) for v in args.bbox.split(","))
602
+ except ValueError:
603
+ raise InputError(f"--bbox must be W,S,E,N numbers, got {args.bbox!r}") from None
604
+ if not (west < east and south < north):
605
+ raise InputError("--bbox needs west < east and south < north")
606
+ return gpd.GeoDataFrame(geometry=[box(west, south, east, north)], crs="EPSG:4326")
607
+ extent = _read_layer(args.extent, args.extent_layer, "extent")
608
+ if extent.empty or not np.isfinite(extent.total_bounds).all():
609
+ raise InputError(f"The extent file has no features with geometry: {args.extent}")
610
+ return gpd.GeoDataFrame(geometry=[box(*extent.total_bounds)], crs=extent.crs)
611
+
612
+
613
+ def _configure_logging(verbose: bool, quiet: bool, emit=None) -> None:
614
+ """Readable logs on stderr; with --json, warnings also become JSON events."""
615
+ level = logging.DEBUG if verbose else logging.WARNING if quiet else logging.INFO
616
+ logging.basicConfig(level=level, format="%(message)s", stream=sys.stderr, force=True)
617
+ library = logging.getLogger("networkforge")
618
+ for handler in [h for h in library.handlers if isinstance(h, _WarningEvents)]:
619
+ library.removeHandler(handler) # from an earlier main() in this process
620
+ if emit is not None:
621
+ library.addHandler(_WarningEvents(emit))
622
+
623
+
624
+ class _WarningEvents(logging.Handler):
625
+ """Turns networkforge warnings into {"event": "warning", ...} JSON events."""
626
+
627
+ def __init__(self, emit):
628
+ super().__init__(level=logging.WARNING)
629
+ self.emit_event = emit
630
+
631
+ def emit(self, record: logging.LogRecord) -> None:
632
+ if record.levelno != logging.WARNING: # errors are reported as error events
633
+ return
634
+ event = {"event": "warning", "message": record.getMessage()}
635
+ if getattr(record, "features", None):
636
+ event["features"] = [feature_id(f) for f in record.features]
637
+ if getattr(record, "fields", None) or getattr(record, "field", None):
638
+ event["fields"] = list(getattr(record, "fields", None) or [record.field])
639
+ if getattr(record, "gaps", None):
640
+ event["gaps"] = [list(bounds) for bounds in record.gaps]
641
+ if getattr(record, "rows", None):
642
+ event["rows"] = [feature_id(r) for r in record.rows]
643
+ self.emit_event(event)
644
+
645
+
646
+ def _emitter(as_json: bool):
647
+ """emit(event, text=None): a JSON line on stdout, or readable text."""
648
+ def emit(event: dict, text: str | None = None) -> None:
649
+ if as_json:
650
+ print(json.dumps(event), flush=True)
651
+ elif event["event"] == "progress":
652
+ pass # the library already logs each step
653
+ elif text is not None:
654
+ print(text, flush=True)
655
+ return emit
656
+
657
+
658
+ def emit_error(emit, exc: Exception) -> None:
659
+ event = {"event": "error", "type": type(exc).__name__, "message": str(exc)}
660
+ if isinstance(exc, NetworkForgeError) and exc.guide:
661
+ event["guide"] = exc.guide
662
+ if isinstance(exc, NetworkForgeError) and exc.issues:
663
+ event["issues"] = exc.issues
664
+ if isinstance(exc, InvalidTagsError):
665
+ event["problems"] = exc.problems
666
+ emit(event, text=None)
667
+ log.error("Error (%s): %s", type(exc).__name__, exc)
668
+
669
+
670
+ def _exit_code(exc: NetworkForgeError) -> int:
671
+ if isinstance(exc, InputError):
672
+ return EXIT_INPUT
673
+ if isinstance(exc, OSMDownloadError):
674
+ return EXIT_DOWNLOAD
675
+ if isinstance(exc, NetworkIntegrityError):
676
+ return EXIT_INTEGRITY
677
+ return EXIT_ERROR
678
+
679
+
680
+ if __name__ == "__main__":
681
+ sys.exit(main())