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/osm.py ADDED
@@ -0,0 +1,450 @@
1
+ """
2
+ Getting the existing OSM network: from the Overpass API (small areas)
3
+ or from a local OSM file such as a Geofabrik .osm.pbf extract (any size).
4
+
5
+ Both return the same thing: nodes and edges GeoDataFrames in the
6
+ analysis CRS, built as ox.graph_from_bbox builds them with
7
+ retain_all=True (500 m buffer, cropped to the box, every piece of
8
+ network kept), so a build from a local extract matches a build from
9
+ Overpass for the same area and data.
10
+
11
+ Both also keep the OSM data itself (OSMSource: every way with its
12
+ nodes and all its tags, tagged nodes, turn restrictions and route
13
+ relations), attached to the edges as `edges.attrs[SOURCE_ATTR]`. The
14
+ edge table only holds the tags OSMnx is told to keep; export writes the
15
+ existing network from the source, so routers get OSM as it is.
16
+ """
17
+
18
+ import logging
19
+ import os
20
+ import time
21
+ from collections import OrderedDict
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ import geopandas as gpd
26
+ import networkx as nx
27
+ import osmium
28
+ import osmnx as ox
29
+ import requests
30
+ import shapely
31
+ from osmnx import _overpass
32
+ from osmnx._errors import InsufficientResponseError, ResponseStatusCodeError
33
+ from osmnx.graph import _create_graph
34
+ from pyproj import CRS
35
+ from shapely.geometry import box
36
+
37
+ from .coverage import warn_about_gaps
38
+ from .errors import InputError, OSMDownloadError
39
+ from .modes import _passes_osmnx_filter, is_ferry, keep_mode_tags
40
+ from .paths import readable
41
+ from .tags import FERRY_ROUTES, RELATION_TYPES, ROUTE_RELATIONS, ROUTING_NODE_KEYS
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ # Where the OSMSource travels: edges.attrs[SOURCE_ATTR].
46
+ SOURCE_ATTR = "networkforge_osm"
47
+
48
+ # OSMnx's built-in network filters, applied in the Overpass query.
49
+ # "all" (the default) keeps every mode's ways plus their access tags,
50
+ # so one build can be routed for any mode later (see modes.py).
51
+ # Narrower types drop ways at download, and custom lines can then
52
+ # only be joined to what's left.
53
+ NETWORK_TYPES = ("all", "all_public", "bike", "drive", "drive_service", "walk")
54
+
55
+ # ox.graph_from_bbox downloads this far around the box, then crops.
56
+ BUFFER_M = 500
57
+
58
+
59
+ def configure_osmnx_cache() -> None:
60
+ """
61
+ Configure the OSMnx cache directory from the
62
+ NETWORKFORGE_OSMNX_CACHE environment variable.
63
+ """
64
+ cache_folder = os.getenv(
65
+ "NETWORKFORGE_OSMNX_CACHE",
66
+ os.path.expanduser("~/.cache/networkforge/osmnx"),
67
+ )
68
+
69
+ os.makedirs(cache_folder, exist_ok=True)
70
+
71
+ ox.settings.cache_folder = cache_folder
72
+
73
+
74
+ def _check_network_type(network_type: str) -> None:
75
+ if network_type not in NETWORK_TYPES:
76
+ raise InputError(
77
+ f"Unknown network_type {network_type!r}. "
78
+ f"Choose one of: {', '.join(NETWORK_TYPES)}"
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True, eq=False)
83
+ class OSMSource:
84
+ """
85
+ The area's OpenStreetMap data as OSM has it. Elements use the
86
+ Overpass JSON shapes OSMnx reads.
87
+
88
+ ways: way id -> (node ids in order, all tags)
89
+ node_tags: node id -> all tags, for nodes a router acts on
90
+ (barriers, signals, crossings ... see ROUTING_NODE_KEYS)
91
+ relations: (id, tags, [(member type "n"/"w"/"r", ref, role), ...])
92
+ for turn restrictions and routes that use a kept way
93
+ ferries: way id -> (node ids, all tags) of ferry routes inside the
94
+ area (see FERRY_ROUTES): written to OSM files as they are
95
+ ferry_nodes: node id -> (lon, lat) of the ferries' nodes
96
+ ferry_terminals: the ferries' end nodes inside the area (not the
97
+ ends of a piece cropped at its edge)
98
+ """
99
+
100
+ ways: dict[int, tuple[list[int], dict[str, str]]] = field(default_factory=dict)
101
+ node_tags: dict[int, dict[str, str]] = field(default_factory=dict)
102
+ relations: list[tuple[int, dict[str, str], list[tuple[str, int, str]]]] = field(
103
+ default_factory=list)
104
+ ferries: dict[int, tuple[list[int], dict[str, str]]] = field(default_factory=dict)
105
+ ferry_nodes: dict[int, tuple[float, float]] = field(default_factory=dict)
106
+ ferry_terminals: set[int] = field(default_factory=set)
107
+
108
+ # pandas deep-copies .attrs on most operations; the source is
109
+ # read-only, so share it rather than copy a whole city each time.
110
+ def __deepcopy__(self, memo):
111
+ return self
112
+
113
+ def __copy__(self):
114
+ return self
115
+
116
+
117
+ def _keeps_relation(tags: dict[str, str]) -> bool:
118
+ kind = tags.get("type", "")
119
+ if kind in RELATION_TYPES or kind.startswith("restriction:"):
120
+ return True
121
+ return kind == "route" and tags.get("route") in ROUTE_RELATIONS
122
+
123
+
124
+ def _source_from_elements(elements: list[dict], polygon) -> OSMSource:
125
+ """`polygon`: the area itself (not buffered); ferries are cropped to it."""
126
+ ways, node_tags, relations = {}, {}, []
127
+ ferries, ferry_nodes, ferry_terminals, locations = {}, {}, set(), {}
128
+ for element in elements:
129
+ tags = element.get("tags") or {}
130
+ if element["type"] == "way" and not is_ferry(tags):
131
+ ways[element["id"]] = (list(element["nodes"]), dict(tags))
132
+ elif element["type"] == "node":
133
+ locations[element["id"]] = (element["lon"], element["lat"])
134
+ if any(key in tags for key in ROUTING_NODE_KEYS):
135
+ node_tags[element["id"]] = dict(tags)
136
+
137
+ extra_ids = iter(range(-(10**15), 0)) # for further pieces of a cropped ferry
138
+ for element in elements:
139
+ if element["type"] != "way" or not is_ferry(element.get("tags") or {}):
140
+ continue
141
+ inside = [(ref, locations[ref]) if ref in locations and shapely.contains_xy(
142
+ polygon, *locations[ref]) else (ref, None) for ref in element["nodes"]]
143
+ for number, run in enumerate(_known_runs(inside)):
144
+ way_id = element["id"] if number == 0 else next(extra_ids)
145
+ ferries[way_id] = ([ref for ref, _ in run], dict(element["tags"]))
146
+ ferry_nodes.update(run)
147
+ ferry_terminals |= {ref for ref, where in (inside[0], inside[-1]) if where}
148
+ for element in elements:
149
+ if element["type"] != "relation" or not _keeps_relation(element.get("tags") or {}):
150
+ continue
151
+ members = [(m["type"][0], m["ref"], m.get("role", "")) for m in element["members"]]
152
+ if any(kind == "w" and ref in ways for kind, ref, _ in members):
153
+ relations.append((element["id"], dict(element["tags"]), members))
154
+ return OSMSource(ways, node_tags, relations, ferries, ferry_nodes, ferry_terminals)
155
+
156
+
157
+ def _network_from_elements(
158
+ elements: list[dict],
159
+ polygon,
160
+ polygon_buffered,
161
+ network_type: str,
162
+ analysis_crs: CRS,
163
+ ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
164
+ """
165
+ Nodes and edges from OSM elements covering `polygon_buffered`: the
166
+ same steps as ox.graph_from_polygon (what graph_from_bbox uses).
167
+ """
168
+ # The street network: every way but the ferries, and those ways' nodes.
169
+ streets = [e for e in elements
170
+ if e["type"] == "way" and not is_ferry(e.get("tags") or {})]
171
+ on_streets = {ref for way in streets for ref in way["nodes"]}
172
+ graph_buffered = _create_graph(
173
+ [{"elements": streets + [e for e in elements
174
+ if e["type"] == "node" and e["id"] in on_streets]}],
175
+ bidirectional=network_type in ox.settings.bidirectional_network_types,
176
+ )
177
+ # Every piece of network in the area is kept (OSMnx's retain_all), not
178
+ # only the largest: other islands, streets that join the rest outside
179
+ # the area, isolated paths. They are part of OpenStreetMap, routers
180
+ # use them, and Valhalla sets speeds by how dense the streets around
181
+ # an edge are - leaving pieces out changed travel times on the rest.
182
+ graph_buffered = ox.truncate.truncate_graph_polygon(graph_buffered, polygon_buffered)
183
+ graph = ox.truncate.truncate_graph_polygon(graph_buffered, polygon)
184
+ street_counts = ox.stats.count_streets_per_node(graph_buffered, nodes=graph.nodes)
185
+ nx.set_node_attributes(graph, values=street_counts, name="street_count")
186
+
187
+ nodes, edges = ox.graph_to_gdfs(graph)
188
+ nodes, edges = nodes.to_crs(analysis_crs), edges.to_crs(analysis_crs).reset_index()
189
+ edges.attrs[SOURCE_ATTR] = _source_from_elements(elements, polygon)
190
+ return nodes, edges
191
+
192
+
193
+ def _buffered(polygon, bbox: gpd.GeoDataFrame):
194
+ """`polygon` (WGS84) grown by BUFFER_M, as ox.graph_from_polygon does."""
195
+ utm = bbox.estimate_utm_crs()
196
+ buffered = gpd.GeoSeries([polygon], crs="EPSG:4326").to_crs(utm).buffer(BUFFER_M)
197
+ return buffered.to_crs("EPSG:4326").iloc[0]
198
+
199
+
200
+ def get_osm_data_from_bbox(
201
+ bbox: gpd.GeoDataFrame,
202
+ analysis_crs: CRS,
203
+ network_type: str = "all",
204
+ ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
205
+ """Download an OSM network within a bounding box from the Overpass API."""
206
+
207
+ _check_network_type(network_type)
208
+
209
+ # Keep motor_vehicle / foot / bicycle etc., which OSMnx drops by
210
+ # default, so access restrictions survive into the edge table.
211
+ keep_mode_tags()
212
+
213
+ # Otherwise OSMnx caches responses in ./cache wherever it's run from.
214
+ configure_osmnx_cache()
215
+
216
+ polygon = box(*bbox.to_crs("EPSG:4326").total_bounds)
217
+ polygon_buffered = _buffered(polygon, bbox)
218
+
219
+ try:
220
+ elements = _download_with_retries(polygon_buffered, network_type)
221
+ return _network_from_elements(
222
+ elements, polygon, polygon_buffered, network_type, analysis_crs)
223
+ except (InsufficientResponseError, ValueError) as exc:
224
+ raise OSMDownloadError(
225
+ f"OpenStreetMap has no {network_type!r} ways in this bounding box "
226
+ f"({exc}). Check the box covers an area with streets."
227
+ ) from exc
228
+ except (requests.RequestException, ResponseStatusCodeError) as exc:
229
+ raise OSMDownloadError(
230
+ f"Couldn't download OpenStreetMap data from the Overpass API ({exc}). "
231
+ "Check your internet connection, or try again later - Overpass "
232
+ "rate-limits heavy use."
233
+ ) from exc
234
+
235
+
236
+ # Seconds to wait before each new attempt when Overpass can't be reached
237
+ # (it refuses connections for a while when busy).
238
+ DOWNLOAD_RETRY_WAITS = (10, 30)
239
+
240
+
241
+ def _download_with_retries(polygon, network_type: str) -> list[dict]:
242
+ """_download_elements, tried again after a pause if Overpass can't be reached."""
243
+ for wait in (*DOWNLOAD_RETRY_WAITS, None):
244
+ try:
245
+ return _download_elements(polygon, network_type)
246
+ except requests.ConnectionError as exc:
247
+ if wait is None:
248
+ raise
249
+ log.warning("Couldn't reach the Overpass API (%s); trying again in %d s.",
250
+ type(exc).__name__, wait)
251
+ time.sleep(wait)
252
+ raise AssertionError("unreachable") # pragma: no cover
253
+
254
+
255
+ def _download_elements(polygon, network_type: str) -> list[dict]:
256
+ """
257
+ Every way OSMnx's filter for `network_type` matches inside `polygon`,
258
+ with its nodes (OSMnx's own query), plus the turn restrictions and
259
+ routes those ways belong to, and the ferry routes in the area with
260
+ their nodes (a second query). Overpass JSON elements.
261
+ """
262
+ elements = [
263
+ element
264
+ for response in _overpass._download_overpass_network(polygon, network_type, None)
265
+ for element in response["elements"]
266
+ ]
267
+
268
+ settings = _overpass._make_overpass_settings()
269
+ way_filter = _overpass._get_network_filter(network_type)
270
+ kinds = "|".join(RELATION_TYPES)
271
+ routes = "|".join(ROUTE_RELATIONS)
272
+ ferries = "|".join(FERRY_ROUTES)
273
+ for coords in _overpass._make_overpass_polygon_coord_strs(polygon):
274
+ query = (
275
+ f"{settings};way{way_filter}(poly:{coords!r})->.w;"
276
+ f'way["route"~"^({ferries})$"]["highway"!~"."](poly:{coords!r})->.f;'
277
+ f'(rel(bw.w)["type"~"^({kinds})(:|$)"];'
278
+ f'rel(bw.w)["type"="route"]["route"~"^({routes})$"];'
279
+ f".f;node(w.f););out;"
280
+ )
281
+ elements += _overpass._overpass_request(OrderedDict(data=query))["elements"]
282
+
283
+ # Large areas are downloaded in pieces that overlap.
284
+ return list({(e["type"], e["id"]): e for e in elements}.values())
285
+
286
+
287
+ def get_osm_data_from_file(
288
+ osm_file: str | Path,
289
+ bbox: gpd.GeoDataFrame,
290
+ analysis_crs: CRS,
291
+ network_type: str = "all",
292
+ ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
293
+ """
294
+ Read the OSM network within a bounding box from a local OSM file
295
+ (.osm.pbf, .osm, .osm.bz2 ... anything osmium reads), e.g. a
296
+ Geofabrik extract. No size limit and no network access.
297
+ """
298
+
299
+ _check_network_type(network_type)
300
+
301
+ path = Path(osm_file)
302
+ if not path.is_file():
303
+ raise InputError(f"OSM file not found: {path}", guide="osm-data.md#getting-an-extract")
304
+
305
+ keep_mode_tags()
306
+
307
+ polygon = bbox.to_crs("EPSG:4326").union_all()
308
+ polygon_buffered = _buffered(polygon, bbox)
309
+
310
+ _check_file_covers(path, polygon)
311
+
312
+ coastlines = []
313
+ elements = _read_elements(path, polygon_buffered.bounds, network_type, coastlines)
314
+ if not any(element["type"] == "way" and not is_ferry(element["tags"])
315
+ for element in elements):
316
+ raise InputError(
317
+ f"{path.name} has no {network_type!r} ways inside the bounding box. "
318
+ "Check the file covers the area.",
319
+ guide="osm-data.md#getting-an-extract",
320
+ )
321
+ warn_about_gaps(elements, coastlines, polygon, analysis_crs, path.name)
322
+
323
+ return _network_from_elements(elements, polygon, polygon_buffered, network_type, analysis_crs)
324
+
325
+
326
+ def _check_file_covers(path: Path, polygon) -> None:
327
+ """Refuse a file whose header says it doesn't reach the area."""
328
+ try:
329
+ with (readable(path) as name,
330
+ osmium.io.Reader(name, osmium.osm.osm_entity_bits.NOTHING) as reader):
331
+ box = reader.header().box()
332
+ except RuntimeError as exc:
333
+ raise InputError(f"Can't read {path.name} as an OSM file ({exc}).") from exc
334
+
335
+ if not box.valid(): # many files don't record their extent
336
+ return
337
+ west, south = box.bottom_left.lon, box.bottom_left.lat
338
+ east, north = box.top_right.lon, box.top_right.lat
339
+ min_lon, min_lat, max_lon, max_lat = polygon.bounds
340
+ if max_lon < west or min_lon > east or max_lat < south or min_lat > north:
341
+ raise InputError(
342
+ f"{path.name} covers lon {west:.3f}..{east:.3f}, lat {south:.3f}..{north:.3f}, "
343
+ "which doesn't overlap the bounding box.",
344
+ guide="osm-data.md#getting-an-extract",
345
+ )
346
+
347
+
348
+ def _read_elements(path: Path, bounds, network_type: str,
349
+ coastlines: list | None = None) -> list[dict]:
350
+ """
351
+ The ways in `path` within `bounds` (filtered like the Overpass query
352
+ for network_type) with their nodes, and the relations kept with them
353
+ (see OSMSource), as Overpass JSON elements. `coastlines`, if given,
354
+ gets the [(lon, lat), ...] of every natural=coastline way reaching
355
+ `bounds` (for coverage.warn_about_gaps: which side is sea).
356
+ """
357
+ west, south, east, north = bounds
358
+
359
+ def inside(lon: float, lat: float) -> bool:
360
+ return west <= lon <= east and south <= lat <= north
361
+
362
+ tagged_nodes: dict[int, dict[str, str]] = {}
363
+ locations: dict[int, tuple[float, float]] = {}
364
+ ways, relations = [], []
365
+ cut_ways = 0
366
+ extra_way_ids = iter(range(-1, -(10**15), -1)) # ids for pieces of cut ways
367
+
368
+ # Only objects with a highway, node-routing or relation-type tag reach
369
+ # Python; the location cache still sees every node, so ways get coordinates.
370
+ try:
371
+ with readable(path) as name:
372
+ processor = (
373
+ osmium.FileProcessor(name)
374
+ .with_locations()
375
+ .with_filter(osmium.filter.KeyFilter(
376
+ "highway", "type", "route", "natural", *ROUTING_NODE_KEYS))
377
+ )
378
+ for obj in processor:
379
+ if obj.is_node():
380
+ if inside(obj.location.lon, obj.location.lat):
381
+ tags = {tag.k: tag.v for tag in obj.tags}
382
+ if any(key in tags for key in ROUTING_NODE_KEYS):
383
+ tagged_nodes[obj.id] = tags
384
+ continue
385
+
386
+ tags = {tag.k: tag.v for tag in obj.tags}
387
+ if obj.is_relation():
388
+ if _keeps_relation(tags):
389
+ members = [{"type": {"n": "node", "w": "way", "r": "relation"}[m.type],
390
+ "ref": m.ref, "role": m.role} for m in obj.members]
391
+ relations.append({"type": "relation", "id": obj.id, "tags": tags,
392
+ "members": members})
393
+ continue
394
+ if not obj.is_way():
395
+ continue
396
+ if tags.get("natural") == "coastline" and "highway" not in tags:
397
+ if coastlines is not None:
398
+ line = [(n.location.lon, n.location.lat) for n in obj.nodes
399
+ if n.location.valid()]
400
+ if any(inside(*loc) for loc in line):
401
+ coastlines.append(line)
402
+ continue
403
+
404
+ if not is_ferry(tags) and (
405
+ "highway" not in tags or not _passes_osmnx_filter(tags, network_type)
406
+ ):
407
+ continue
408
+ nodes = [(n.ref, (n.location.lon, n.location.lat)) if n.location.valid()
409
+ else (n.ref, None) for n in obj.nodes]
410
+ if not any(loc and inside(*loc) for _, loc in nodes):
411
+ continue
412
+
413
+ # An extract can cut a way at its edge, leaving refs to nodes it
414
+ # doesn't contain. Keep each unbroken run of known nodes.
415
+ runs = _known_runs(nodes)
416
+ if len(runs) != 1 or len(runs[0]) != len(nodes):
417
+ cut_ways += 1
418
+ for number, run in enumerate(runs):
419
+ way_id = obj.id if number == 0 else next(extra_way_ids)
420
+ ways.append({"type": "way", "id": way_id, "tags": tags,
421
+ "nodes": [ref for ref, _ in run]})
422
+ locations.update(run)
423
+ except RuntimeError as exc:
424
+ raise InputError(f"Can't read {path.name} as an OSM file ({exc}).") from exc
425
+
426
+ if cut_ways:
427
+ log.warning("%d way(s) in %s reference nodes the file doesn't contain (cut at "
428
+ "the extract's edge); kept the parts it does contain. Use an "
429
+ "extract that fully covers the area to avoid gaps.", cut_ways, path.name)
430
+
431
+ nodes = [
432
+ {"type": "node", "id": ref, "lon": lon, "lat": lat, "tags": tagged_nodes.get(ref, {})}
433
+ for ref, (lon, lat) in sorted(locations.items())
434
+ ]
435
+ return [*nodes, *sorted(ways, key=lambda way: way["id"]), *relations]
436
+
437
+
438
+ def _known_runs(nodes: list[tuple[int, tuple[float, float] | None]]) -> list[list]:
439
+ """Split (ref, location-or-None) into runs of 2+ consecutive known nodes."""
440
+ runs, current = [], []
441
+ for ref, location in nodes:
442
+ if location is None:
443
+ if len(current) >= 2:
444
+ runs.append(current)
445
+ current = []
446
+ else:
447
+ current.append((ref, location))
448
+ if len(current) >= 2:
449
+ runs.append(current)
450
+ return runs
networkforge/paths.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ File names osmium can open on Windows.
3
+
4
+ osmium (the library that reads and writes .osm.pbf) opens files through
5
+ an old Windows interface that doesn't take Unicode names: a file in
6
+ "C:\\Users\\Zoë\\Documents" is "not found". Python itself has no such
7
+ trouble, so the two helpers here let Python do the part with the
8
+ awkward letters:
9
+
10
+ - the folder: Python makes it the working folder, and osmium is given
11
+ the bare file name;
12
+ - the file name, if that has such letters too: osmium reads a second,
13
+ plainly named entry for the same file (a hard link, no copy), or
14
+ writes to a plainly named file that is renamed when it is finished.
15
+
16
+ Everywhere else, and for plain ASCII paths on Windows, the path is
17
+ passed on untouched.
18
+ """
19
+
20
+ import os
21
+ import uuid
22
+ from collections.abc import Iterator
23
+ from contextlib import contextmanager
24
+ from pathlib import Path
25
+
26
+ from .errors import InputError
27
+
28
+ # Longest first: "x.osm.pbf" ends in ".pbf" too.
29
+ OSM_ENDINGS = (".osm.pbf", ".osm.bz2", ".osm.gz", ".pbf", ".osm")
30
+
31
+ # Whether osmium needs this help (tests switch it on to try it anywhere).
32
+ _NARROW_NAMES_ONLY = os.name == "nt"
33
+
34
+
35
+ def _needs_help(path: Path) -> bool:
36
+ return _NARROW_NAMES_ONLY and not str(path).isascii() and path.parent.is_dir()
37
+
38
+
39
+ def _plain_name(name: str) -> str:
40
+ """A name of plain letters that osmium reads as the same format as `name`."""
41
+ ending = next((e for e in OSM_ENDINGS if name.lower().endswith(e)), "")
42
+ return f"networkforge-{uuid.uuid4().hex}{ending}"
43
+
44
+
45
+ @contextmanager
46
+ def _working_in(folder: Path) -> Iterator[None]:
47
+ before = os.getcwd()
48
+ os.chdir(folder)
49
+ try:
50
+ yield
51
+ finally:
52
+ os.chdir(before)
53
+
54
+
55
+ def _remove(name: str) -> None:
56
+ try:
57
+ os.unlink(name)
58
+ except OSError: # never there, or Windows still has it open
59
+ pass
60
+
61
+
62
+ @contextmanager
63
+ def readable(path: str | Path) -> Iterator[str]:
64
+ """
65
+ Give the name to hand to osmium to read `path`. Keep reading inside
66
+ the `with` block: the name is only good there.
67
+ """
68
+ path = Path(path)
69
+ if not _needs_help(path):
70
+ yield str(path)
71
+ return
72
+
73
+ path = path.absolute()
74
+ with _working_in(path.parent):
75
+ if path.name.isascii():
76
+ yield path.name
77
+ return
78
+ link = _plain_name(path.name)
79
+ try:
80
+ os.link(path.name, link)
81
+ except OSError as exc:
82
+ raise InputError(
83
+ f"Can't open {path.name}: on Windows, give the file a name without "
84
+ "accented or other special letters and try again."
85
+ ) from exc
86
+ try:
87
+ yield link
88
+ finally:
89
+ _remove(link)
90
+
91
+
92
+ @contextmanager
93
+ def writable(path: str | Path) -> Iterator[str]:
94
+ """
95
+ Give the name to hand to osmium to write `path`. The file is in
96
+ place under its real name when the `with` block ends without error.
97
+ """
98
+ path = Path(path)
99
+ if not _needs_help(path):
100
+ yield str(path)
101
+ return
102
+
103
+ path = path.absolute()
104
+ with _working_in(path.parent):
105
+ if path.name.isascii():
106
+ yield path.name
107
+ return
108
+ unfinished = _plain_name(path.name)
109
+ try:
110
+ yield unfinished
111
+ os.replace(unfinished, path.name)
112
+ finally:
113
+ _remove(unfinished)