pathview-plus 2.0.0__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.
@@ -0,0 +1,353 @@
1
+ """
2
+ sbgn_parser.py
3
+ Parse SBGN-ML (Systems Biology Graphical Notation ML) files.
4
+
5
+ SBGN is used by Reactome, MetaCyc, MetaCrop, PANTHER, and SMPDB.
6
+ Supports Process Description (PD), Entity Relationship (ER), and Activity Flow (AF) languages.
7
+
8
+ Public API
9
+ ----------
10
+ parse_sbgn : Path → SBGNPathway
11
+ sbgn_to_df : SBGNPathway → pl.DataFrame (unified with KGML format)
12
+
13
+ SBGN vs KGML differences:
14
+ - Glyphs (nodes) instead of entries
15
+ - Arcs (edges) with Bezier splines
16
+ - Compartments (cellular locations)
17
+ - Clone markers for repeated entities
18
+ - Process nodes (reactions, associations)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Optional
26
+ from xml.etree import ElementTree as ET
27
+
28
+ import polars as pl
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Dataclasses
33
+ # ---------------------------------------------------------------------------
34
+
35
+ @dataclass
36
+ class SBGNGlyph:
37
+ """One <glyph> element from SBGN-ML."""
38
+ glyph_id: str
39
+ glyph_class: str # macromolecule, simple chemical, process, etc.
40
+ label: str = ""
41
+ x: Optional[float] = None
42
+ y: Optional[float] = None
43
+ width: Optional[float] = None
44
+ height: Optional[float] = None
45
+ compartment: Optional[str] = None
46
+ clone_marker: bool = False
47
+ state_variables: list[dict] = field(default_factory=list)
48
+ unit_of_information: list[dict] = field(default_factory=list)
49
+
50
+
51
+ @dataclass
52
+ class SBGNArc:
53
+ """One <arc> element from SBGN-ML."""
54
+ arc_id: str
55
+ arc_class: str # production, consumption, catalysis, inhibition, etc.
56
+ source: str
57
+ target: str
58
+ spline_points: list[tuple[float, float]] = field(default_factory=list)
59
+
60
+
61
+ @dataclass
62
+ class SBGNPathway:
63
+ """Container for all parsed elements of an SBGN-ML file."""
64
+ pathway_id: str
65
+ pathway_name: str
66
+ glyphs: dict[str, SBGNGlyph] = field(default_factory=dict)
67
+ arcs: list[SBGNArc] = field(default_factory=list)
68
+ compartments: dict[str, SBGNGlyph] = field(default_factory=dict)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # SBGN glyph classes mapping
73
+ # ---------------------------------------------------------------------------
74
+
75
+ # Map SBGN glyph classes to simplified types for unified interface
76
+ _GLYPH_TYPE_MAP = {
77
+ # Entity pool nodes (EPN)
78
+ "macromolecule": "gene",
79
+ "simple chemical": "compound",
80
+ "nucleic acid feature": "gene",
81
+ "complex": "gene",
82
+ "multimer": "gene",
83
+ "unspecified entity": "gene",
84
+
85
+ # Process nodes (PN)
86
+ "process": "process",
87
+ "omitted process": "process",
88
+ "uncertain process": "process",
89
+ "association": "process",
90
+ "dissociation": "process",
91
+ "phenotype": "process",
92
+
93
+ # Container nodes
94
+ "compartment": "compartment",
95
+ "submap": "map",
96
+
97
+ # Logical operators
98
+ "and": "operator",
99
+ "or": "operator",
100
+ "not": "operator",
101
+ }
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # XML parsing helpers
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _parse_bbox(elem: ET.Element) -> dict:
109
+ """Extract bounding box from <bbox> child element."""
110
+ bbox = elem.find(".//bbox", namespaces={"": "http://sbgn.org/libsbgn/0.2"})
111
+ if bbox is None:
112
+ bbox = elem.find("bbox") # Try without namespace
113
+
114
+ if bbox is not None:
115
+ return {
116
+ "x": float(bbox.get("x", 0)) + float(bbox.get("w", 46)) / 2,
117
+ "y": float(bbox.get("y", 0)) + float(bbox.get("h", 17)) / 2,
118
+ "width": float(bbox.get("w", 46)),
119
+ "height": float(bbox.get("h", 17)),
120
+ }
121
+ return {}
122
+
123
+
124
+ def _parse_label(elem: ET.Element) -> str:
125
+ """Extract label text from <label> child element."""
126
+ label = elem.find(".//label", namespaces={"": "http://sbgn.org/libsbgn/0.2"})
127
+ if label is None:
128
+ label = elem.find("label")
129
+
130
+ if label is not None:
131
+ return label.get("text", "")
132
+ return ""
133
+
134
+
135
+ def _parse_state_variables(elem: ET.Element) -> list[dict]:
136
+ """Parse state variable glyphs (child glyphs with class='state variable')."""
137
+ states = []
138
+ for child in elem.findall(".//glyph[@class='state variable']"):
139
+ states.append({
140
+ "variable": child.get("variable", ""),
141
+ "value": child.get("value", ""),
142
+ "label": _parse_label(child),
143
+ })
144
+ return states
145
+
146
+
147
+ def _parse_spline(arc_elem: ET.Element) -> list[tuple[float, float]]:
148
+ """
149
+ Parse spline curve from arc.
150
+
151
+ SBGN-ML can have:
152
+ 1. Straight lines: <start> and <end> points
153
+ 2. Bezier curves: <start>, multiple <next>, <end> with control points
154
+ """
155
+ points = []
156
+
157
+ # Start point
158
+ start = arc_elem.find("start")
159
+ if start is not None:
160
+ points.append((float(start.get("x", 0)), float(start.get("y", 0))))
161
+
162
+ # Intermediate points (could be Bezier control points)
163
+ for pt in arc_elem.findall("next"):
164
+ points.append((float(pt.get("x", 0)), float(pt.get("y", 0))))
165
+
166
+ # End point
167
+ end = arc_elem.find("end")
168
+ if end is not None:
169
+ points.append((float(end.get("x", 0)), float(end.get("y", 0))))
170
+
171
+ return points
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Main parsing functions
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def parse_sbgn(filepath: str | Path) -> SBGNPathway:
179
+ """
180
+ Parse an SBGN-ML file and return a populated SBGNPathway.
181
+
182
+ Parameters
183
+ ----------
184
+ filepath: Path to the .sbgn or .xml SBGN-ML file
185
+
186
+ Returns
187
+ -------
188
+ SBGNPathway object with all glyphs, arcs, and compartments
189
+
190
+ Example
191
+ -------
192
+ >>> pathway = parse_sbgn("R-HSA-109582.sbgn")
193
+ >>> print(f"Found {len(pathway.glyphs)} glyphs")
194
+ >>> df = sbgn_to_df(pathway)
195
+ """
196
+ tree = ET.parse(filepath)
197
+ root = tree.getroot()
198
+
199
+ # Handle namespace (SBGN files often use xmlns)
200
+ ns = {"sbgn": "http://sbgn.org/libsbgn/0.2"}
201
+ map_elem = root.find(".//sbgn:map", ns)
202
+ if map_elem is None:
203
+ map_elem = root.find("map")
204
+ if map_elem is None:
205
+ map_elem = root # Fall back to root
206
+
207
+ pathway = SBGNPathway(
208
+ pathway_id=map_elem.get("id", Path(filepath).stem),
209
+ pathway_name=map_elem.get("language", "SBGN-PD"),
210
+ )
211
+
212
+ # Parse all glyphs
213
+ for glyph_elem in map_elem.findall(".//glyph"):
214
+ glyph_id = glyph_elem.get("id", "")
215
+ glyph_class = glyph_elem.get("class", "")
216
+
217
+ if not glyph_id:
218
+ continue
219
+
220
+ bbox = _parse_bbox(glyph_elem)
221
+ label = _parse_label(glyph_elem)
222
+
223
+ glyph = SBGNGlyph(
224
+ glyph_id=glyph_id,
225
+ glyph_class=glyph_class,
226
+ label=label,
227
+ x=bbox.get("x"),
228
+ y=bbox.get("y"),
229
+ width=bbox.get("width"),
230
+ height=bbox.get("height"),
231
+ compartment=glyph_elem.get("compartmentRef"),
232
+ clone_marker="clone" in glyph_elem.attrib.get("clone", "").lower(),
233
+ state_variables=_parse_state_variables(glyph_elem),
234
+ )
235
+
236
+ if glyph_class == "compartment":
237
+ pathway.compartments[glyph_id] = glyph
238
+ else:
239
+ pathway.glyphs[glyph_id] = glyph
240
+
241
+ # Parse all arcs
242
+ for arc_elem in map_elem.findall(".//arc"):
243
+ arc_id = arc_elem.get("id", "")
244
+ arc_class = arc_elem.get("class", "")
245
+ source = arc_elem.get("source", "")
246
+ target = arc_elem.get("target", "")
247
+
248
+ if not all([arc_id, source, target]):
249
+ continue
250
+
251
+ arc = SBGNArc(
252
+ arc_id=arc_id,
253
+ arc_class=arc_class,
254
+ source=source,
255
+ target=target,
256
+ spline_points=_parse_spline(arc_elem),
257
+ )
258
+ pathway.arcs.append(arc)
259
+
260
+ return pathway
261
+
262
+
263
+ def sbgn_to_df(pathway: SBGNPathway) -> pl.DataFrame:
264
+ """
265
+ Convert SBGN pathway to a unified Polars DataFrame (compatible with KGML format).
266
+
267
+ Parameters
268
+ ----------
269
+ pathway: Parsed SBGNPathway object
270
+
271
+ Returns
272
+ -------
273
+ DataFrame with columns matching KGML node_info format:
274
+ entry_id, name, type, x, y, width, height, label, shape, etc.
275
+
276
+ This allows SBGN pathways to use the same rendering pipeline as KEGG.
277
+ """
278
+ records = []
279
+
280
+ for glyph_id, glyph in pathway.glyphs.items():
281
+ # Map SBGN class to simplified type
282
+ node_type = _GLYPH_TYPE_MAP.get(glyph.glyph_class, "unknown")
283
+
284
+ # Determine shape
285
+ shape_map = {
286
+ "macromolecule": "roundedrectangle",
287
+ "simple chemical": "ellipse",
288
+ "complex": "octagon",
289
+ "process": "square",
290
+ }
291
+ shape = shape_map.get(glyph.glyph_class, "rectangle")
292
+
293
+ records.append({
294
+ "entry_id": glyph_id,
295
+ "name": glyph_id, # SBGN IDs are typically database IDs
296
+ "type": node_type,
297
+ "x": glyph.x,
298
+ "y": glyph.y,
299
+ "width": glyph.width,
300
+ "height": glyph.height,
301
+ "bgcolor": "#FFFFFF",
302
+ "label": glyph.label or glyph_id,
303
+ "shape": shape,
304
+ "reaction": "",
305
+ "component": "",
306
+ "size": 1,
307
+ "kegg_names": glyph_id, # For ID mapping
308
+ })
309
+
310
+ return pl.DataFrame(records) if records else pl.DataFrame()
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # SBGN glyph class reference
315
+ # ---------------------------------------------------------------------------
316
+
317
+ SBGN_GLYPH_CLASSES = {
318
+ # Entity Pool Nodes (EPN)
319
+ "macromolecule": "Protein, gene product",
320
+ "simple chemical": "Small molecule, metabolite",
321
+ "nucleic acid feature": "DNA, RNA fragment",
322
+ "complex": "Molecular complex",
323
+ "multimer": "Homogeneous multimer",
324
+ "unspecified entity": "Unknown entity type",
325
+
326
+ # Process Nodes (PN)
327
+ "process": "Biochemical process",
328
+ "omitted process": "Process details omitted",
329
+ "uncertain process": "Uncertain process",
330
+ "association": "Complex formation",
331
+ "dissociation": "Complex dissociation",
332
+ "phenotype": "Observable phenotype",
333
+
334
+ # Containers
335
+ "compartment": "Cellular compartment",
336
+ "submap": "Link to another map",
337
+
338
+ # Logical operators
339
+ "and": "Logical AND",
340
+ "or": "Logical OR",
341
+ "not": "Logical NOT",
342
+ }
343
+
344
+ SBGN_ARC_CLASSES = {
345
+ "production": "Product of process",
346
+ "consumption": "Consumed by process",
347
+ "catalysis": "Catalyzes process",
348
+ "modulation": "Modulates process",
349
+ "stimulation": "Stimulates process",
350
+ "inhibition": "Inhibits process",
351
+ "necessary stimulation": "Required stimulator",
352
+ "logic arc": "Logical operator connection",
353
+ }
pathview/splines.py ADDED
@@ -0,0 +1,304 @@
1
+ """
2
+ splines.py
3
+ Bezier curve (spline) rendering for pathway edges.
4
+
5
+ Provides smoother, more aesthetically pleasing edge routing compared to
6
+ straight lines. Particularly useful for complex pathways with many crossings.
7
+
8
+ Public API
9
+ ----------
10
+ cubic_bezier : Calculate points along a cubic Bezier curve
11
+ quadratic_bezier : Calculate points along a quadratic Bezier curve
12
+ catmull_rom_spline : Calculate smooth curve through control points
13
+ route_edge_spline : Auto-route an edge avoiding obstacles
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Bezier curves
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def cubic_bezier(
28
+ p0: tuple[float, float],
29
+ p1: tuple[float, float],
30
+ p2: tuple[float, float],
31
+ p3: tuple[float, float],
32
+ n_points: int = 50,
33
+ ) -> np.ndarray:
34
+ """
35
+ Calculate points along a cubic Bezier curve.
36
+
37
+ A cubic Bezier is defined by 4 control points:
38
+ - p0: start point
39
+ - p1: first control point
40
+ - p2: second control point
41
+ - p3: end point
42
+
43
+ Parameters
44
+ ----------
45
+ p0, p1, p2, p3: Control points as (x, y) tuples
46
+ n_points: Number of points to sample along the curve
47
+
48
+ Returns
49
+ -------
50
+ Array of shape (n_points, 2) containing (x, y) coordinates.
51
+
52
+ Example
53
+ -------
54
+ >>> curve = cubic_bezier((0, 0), (1, 2), (3, 2), (4, 0), n_points=100)
55
+ >>> plt.plot(curve[:, 0], curve[:, 1])
56
+ """
57
+ t = np.linspace(0, 1, n_points)[:, np.newaxis]
58
+
59
+ # Cubic Bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
60
+ p0 = np.array(p0)
61
+ p1 = np.array(p1)
62
+ p2 = np.array(p2)
63
+ p3 = np.array(p3)
64
+
65
+ curve = (
66
+ (1 - t)**3 * p0
67
+ + 3 * (1 - t)**2 * t * p1
68
+ + 3 * (1 - t) * t**2 * p2
69
+ + t**3 * p3
70
+ )
71
+ return curve
72
+
73
+
74
+ def quadratic_bezier(
75
+ p0: tuple[float, float],
76
+ p1: tuple[float, float],
77
+ p2: tuple[float, float],
78
+ n_points: int = 50,
79
+ ) -> np.ndarray:
80
+ """
81
+ Calculate points along a quadratic Bezier curve.
82
+
83
+ A quadratic Bezier is defined by 3 control points:
84
+ - p0: start point
85
+ - p1: control point
86
+ - p2: end point
87
+
88
+ Parameters
89
+ ----------
90
+ p0, p1, p2: Control points as (x, y) tuples
91
+ n_points: Number of points to sample
92
+
93
+ Returns array of shape (n_points, 2).
94
+ """
95
+ t = np.linspace(0, 1, n_points)[:, np.newaxis]
96
+
97
+ # Quadratic Bezier: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
98
+ p0 = np.array(p0)
99
+ p1 = np.array(p1)
100
+ p2 = np.array(p2)
101
+
102
+ curve = (1 - t)**2 * p0 + 2 * (1 - t) * t * p1 + t**2 * p2
103
+ return curve
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Catmull-Rom splines
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def catmull_rom_spline(
111
+ points: list[tuple[float, float]],
112
+ n_points: int = 50,
113
+ alpha: float = 0.5,
114
+ ) -> np.ndarray:
115
+ """
116
+ Calculate a smooth Catmull-Rom spline through control points.
117
+
118
+ Catmull-Rom splines pass through all control points (interpolating spline)
119
+ and produce smooth curves. The 'alpha' parameter controls the
120
+ parameterization:
121
+ - alpha = 0.0: uniform (can produce loops)
122
+ - alpha = 0.5: centripetal (most common, no loops/cusps)
123
+ - alpha = 1.0: chordal
124
+
125
+ Parameters
126
+ ----------
127
+ points: List of (x, y) control points to interpolate
128
+ n_points: Number of points to sample between each pair
129
+ alpha: Parameterization (0.5 = centripetal, recommended)
130
+
131
+ Returns array of shape (total_points, 2).
132
+
133
+ Example
134
+ -------
135
+ >>> control_pts = [(0, 0), (1, 2), (3, 1), (4, 3)]
136
+ >>> smooth_curve = catmull_rom_spline(control_pts, n_points=30)
137
+ """
138
+ if len(points) < 2:
139
+ return np.array(points)
140
+
141
+ # Add phantom points at start and end
142
+ p = [points[0]] + points + [points[-1]]
143
+ curves = []
144
+
145
+ for i in range(len(p) - 3):
146
+ p0, p1, p2, p3 = p[i], p[i+1], p[i+2], p[i+3]
147
+
148
+ # Calculate segment lengths
149
+ t0 = 0
150
+ t1 = t0 + _distance(p0, p1) ** alpha
151
+ t2 = t1 + _distance(p1, p2) ** alpha
152
+ t3 = t2 + _distance(p2, p3) ** alpha
153
+
154
+ # Sample points in the valid range [t1, t2]
155
+ t = np.linspace(t1, t2, n_points)
156
+
157
+ # Catmull-Rom basis functions
158
+ for ti in t:
159
+ a1 = (t1 - ti) / (t1 - t0) * np.array(p0) + (ti - t0) / (t1 - t0) * np.array(p1)
160
+ a2 = (t2 - ti) / (t2 - t1) * np.array(p1) + (ti - t1) / (t2 - t1) * np.array(p2)
161
+ a3 = (t3 - ti) / (t3 - t2) * np.array(p2) + (ti - t2) / (t3 - t2) * np.array(p3)
162
+
163
+ b1 = (t2 - ti) / (t2 - t0) * a1 + (ti - t0) / (t2 - t0) * a2
164
+ b2 = (t3 - ti) / (t3 - t1) * a2 + (ti - t1) / (t3 - t1) * a3
165
+
166
+ c = (t2 - ti) / (t2 - t1) * b1 + (ti - t1) / (t2 - t1) * b2
167
+ curves.append(c)
168
+
169
+ return np.array(curves)
170
+
171
+
172
+ def _distance(p1: tuple[float, float], p2: tuple[float, float]) -> float:
173
+ """Euclidean distance between two points."""
174
+ return np.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Auto-routing
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def route_edge_spline(
182
+ source: tuple[float, float],
183
+ target: tuple[float, float],
184
+ obstacles: Optional[list[tuple[float, float, float, float]]] = None,
185
+ routing_mode: str = "orthogonal",
186
+ ) -> np.ndarray:
187
+ """
188
+ Auto-route an edge between source and target, avoiding obstacles.
189
+
190
+ This is a simplified routing algorithm. For production use, consider
191
+ more sophisticated routing like:
192
+ - A* pathfinding
193
+ - Visibility graphs
194
+ - Force-directed edge bundling
195
+
196
+ Parameters
197
+ ----------
198
+ source: (x, y) starting point
199
+ target: (x, y) ending point
200
+ obstacles: List of (x, y, width, height) rectangles to avoid
201
+ routing_mode: "straight", "orthogonal", or "curved"
202
+
203
+ Returns array of points defining the routed path.
204
+ """
205
+ if routing_mode == "straight" or obstacles is None:
206
+ return np.array([source, target])
207
+
208
+ if routing_mode == "orthogonal":
209
+ # Simple orthogonal routing (Manhattan-style)
210
+ sx, sy = source
211
+ tx, ty = target
212
+ midx = (sx + tx) / 2
213
+
214
+ control_points = [
215
+ source,
216
+ (midx, sy),
217
+ (midx, ty),
218
+ target,
219
+ ]
220
+ return catmull_rom_spline(control_points, n_points=20)
221
+
222
+ elif routing_mode == "curved":
223
+ # Gentle S-curve
224
+ sx, sy = source
225
+ tx, ty = target
226
+
227
+ # Control points for cubic Bezier
228
+ dx = tx - sx
229
+ dy = ty - sy
230
+ c1 = (sx + dx * 0.3, sy + dy * 0.1)
231
+ c2 = (sx + dx * 0.7, sy + dy * 0.9)
232
+
233
+ return cubic_bezier(source, c1, c2, target, n_points=30)
234
+
235
+ return np.array([source, target])
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # SVG path generation
240
+ # ---------------------------------------------------------------------------
241
+
242
+ def bezier_to_svg_path(
243
+ curve: np.ndarray,
244
+ close: bool = False,
245
+ ) -> str:
246
+ """
247
+ Convert a Bezier curve to SVG path data.
248
+
249
+ Parameters
250
+ ----------
251
+ curve: Array of shape (n, 2) containing (x, y) points
252
+ close: Whether to close the path (Z command)
253
+
254
+ Returns SVG path data string (for use in <path d="..."/>)
255
+
256
+ Example
257
+ -------
258
+ >>> curve = cubic_bezier((10, 10), (50, 80), (150, 80), (200, 10))
259
+ >>> path_data = bezier_to_svg_path(curve)
260
+ >>> svg = f'<path d="{path_data}" stroke="black" fill="none"/>'
261
+ """
262
+ if len(curve) == 0:
263
+ return ""
264
+
265
+ path_parts = [f"M {curve[0, 0]:.2f} {curve[0, 1]:.2f}"]
266
+
267
+ for point in curve[1:]:
268
+ path_parts.append(f"L {point[0]:.2f} {point[1]:.2f}")
269
+
270
+ if close:
271
+ path_parts.append("Z")
272
+
273
+ return " ".join(path_parts)
274
+
275
+
276
+ def smooth_path_svg(
277
+ points: list[tuple[float, float]],
278
+ tension: float = 0.5,
279
+ ) -> str:
280
+ """
281
+ Generate smooth SVG path using quadratic Bezier commands.
282
+
283
+ Parameters
284
+ ----------
285
+ points: List of (x, y) waypoints
286
+ tension: Curve tension (0 = sharp corners, 1 = very smooth)
287
+
288
+ Returns SVG path data using S (smooth cubic bezier) commands.
289
+ """
290
+ if len(points) < 2:
291
+ return ""
292
+
293
+ path_parts = [f"M {points[0][0]} {points[0][1]}"]
294
+
295
+ for i in range(1, len(points)):
296
+ x, y = points[i]
297
+ if i == 1:
298
+ # First curve segment uses Q (quadratic)
299
+ path_parts.append(f"Q {x} {y} {x} {y}")
300
+ else:
301
+ # Subsequent segments use T (smooth continuation)
302
+ path_parts.append(f"T {x} {y}")
303
+
304
+ return " ".join(path_parts)