grid-generator 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (62) hide show
  1. {grid_generator-0.2.0 → grid_generator-0.3.0}/PKG-INFO +1 -1
  2. {grid_generator-0.2.0 → grid_generator-0.3.0}/pyproject.toml +4 -0
  3. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/body_fitted.py +115 -24
  4. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cartesian.py +26 -1
  5. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cli/app.py +18 -7
  6. grid_generator-0.3.0/src/grid_generator/cli/callbacks.py +47 -0
  7. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cli/cmd_geometry.py +5 -0
  8. grid_generator-0.3.0/src/grid_generator/cli/examples_command.py +179 -0
  9. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/config/body_fitted.py +93 -8
  10. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/config/cartesian.py +28 -2
  11. grid_generator-0.3.0/src/grid_generator/config/markers.py +89 -0
  12. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/config/templates.py +40 -1
  13. grid_generator-0.3.0/src/grid_generator/examples/cone/cone7_m7_rn2p5/cone.toml +46 -0
  14. grid_generator-0.3.0/src/grid_generator/outer.py +312 -0
  15. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/PKG-INFO +1 -1
  16. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/SOURCES.txt +6 -0
  17. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/scm_file_list.json +6 -0
  18. grid_generator-0.3.0/src/grid_generator.egg-info/scm_version.json +8 -0
  19. {grid_generator-0.2.0 → grid_generator-0.3.0}/tests/test_body_fitted.py +76 -1
  20. {grid_generator-0.2.0 → grid_generator-0.3.0}/tests/test_cartesian.py +42 -7
  21. grid_generator-0.3.0/tests/test_examples.py +99 -0
  22. grid_generator-0.3.0/tests/test_outer.py +231 -0
  23. grid_generator-0.2.0/src/grid_generator/cli/callbacks.py +0 -19
  24. grid_generator-0.2.0/src/grid_generator.egg-info/scm_version.json +0 -8
  25. {grid_generator-0.2.0 → grid_generator-0.3.0}/.github/workflows/docs.yml +0 -0
  26. {grid_generator-0.2.0 → grid_generator-0.3.0}/.github/workflows/package.yml +0 -0
  27. {grid_generator-0.2.0 → grid_generator-0.3.0}/.github/workflows/publish.yml +0 -0
  28. {grid_generator-0.2.0 → grid_generator-0.3.0}/.github/workflows/test.yml +0 -0
  29. {grid_generator-0.2.0 → grid_generator-0.3.0}/.gitignore +0 -0
  30. {grid_generator-0.2.0 → grid_generator-0.3.0}/LICENSE +0 -0
  31. {grid_generator-0.2.0 → grid_generator-0.3.0}/README.md +0 -0
  32. {grid_generator-0.2.0 → grid_generator-0.3.0}/docs/index.md +0 -0
  33. {grid_generator-0.2.0 → grid_generator-0.3.0}/docs/installation.md +0 -0
  34. {grid_generator-0.2.0 → grid_generator-0.3.0}/mkdocs.yml +0 -0
  35. {grid_generator-0.2.0 → grid_generator-0.3.0}/setup.cfg +0 -0
  36. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/__init__.py +0 -0
  37. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cli/__init__.py +0 -0
  38. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cli/cmd_cartesian.py +0 -0
  39. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cli/cmd_distribution.py +0 -0
  40. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/cli/help_text.py +0 -0
  41. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/config/__init__.py +0 -0
  42. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/config/distribution.py +0 -0
  43. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/config/points.py +0 -0
  44. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/__init__.py +0 -0
  45. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/catalog.py +0 -0
  46. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/equidistant.py +0 -0
  47. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/generate.py +0 -0
  48. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/geometric.py +0 -0
  49. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/models.py +0 -0
  50. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/parse.py +0 -0
  51. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/polynomial.py +0 -0
  52. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/segmented.py +0 -0
  53. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/tanh.py +0 -0
  54. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/distributions/writers.py +0 -0
  55. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator/quality.py +0 -0
  56. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/dependency_links.txt +0 -0
  57. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/entry_points.txt +0 -0
  58. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/requires.txt +0 -0
  59. {grid_generator-0.2.0 → grid_generator-0.3.0}/src/grid_generator.egg-info/top_level.txt +0 -0
  60. {grid_generator-0.2.0 → grid_generator-0.3.0}/tests/test_distribution_cli.py +0 -0
  61. {grid_generator-0.2.0 → grid_generator-0.3.0}/tests/test_distributions.py +0 -0
  62. {grid_generator-0.2.0 → grid_generator-0.3.0}/tests/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grid-generator
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Structured grid generation utilities for Python
5
5
  Author-email: Christoph Hader <christoph.hader@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -54,6 +54,10 @@ Documentation = "https://uahypersonics.github.io/grid-generator"
54
54
  [tool.setuptools.packages.find]
55
55
  where = ["src"]
56
56
 
57
+ [tool.setuptools.package-data]
58
+ # bundled example cases: examples/<geometry type>/<name>/<files>
59
+ "grid_generator" = ["examples/*/*/*"]
60
+
57
61
  [tool.setuptools_scm]
58
62
 
59
63
  [tool.pytest.ini_options]
@@ -12,8 +12,17 @@ polar about the nose centre), then the configured body distribution starting fro
12
12
  the nose spacing, so the spacing is continuous across the junction. The body may be
13
13
  straight (plate, wedge, cone) or curved (tangent ogive, with an optional cylinder).
14
14
 
15
- No shock alignment yet: H is constant, and the user picks it large enough to
16
- clear the bow shock.
15
+ H is either constant ([domain] height, picked large enough to clear the bow shock),
16
+ or the grid has a shock-aligned outer boundary ([outer]): a smooth curve a margin
17
+ beyond the shock, with the outer points spaced smoothly along it (see
18
+ grid_generator.outer). Each line then leaves the wall along the normal and bends
19
+ toward its outer point,
20
+
21
+ x(i, j) = wall(s_i) + n_j * normal(s_i) + (n_j / h_i)^2 * shift_i,
22
+
23
+ with h_i the outer point's distance along the normal and shift_i its offset across
24
+ it. The wall-normal distribution keeps its first spacing and point count on every
25
+ line; only its stretching follows the line's height.
17
26
 
18
27
  With a [span] table (plate and wedge), the 2D plane is extruded in z: the same
19
28
  (x, y) plane at every z of the span distribution, giving a 3D grid.
@@ -37,26 +46,27 @@ from grid_generator.config.body_fitted import (
37
46
  load_body_fitted_config,
38
47
  )
39
48
  from grid_generator.distributions import DistributionSpec, EquidistantSpec, build_distribution
49
+ from grid_generator.outer import outer_points, read_shock_curve
40
50
 
41
51
  # --------------------------------------------------
42
52
  # constants
43
53
  # --------------------------------------------------
44
54
 
45
- # SU2 / Gmsh boundary names for the four sides of the grid: imin is the stagnation
46
- # line ahead of the nose, imax the outflow at x_end, jmin the wall, jmax the outer
47
- # boundary
48
- BOUNDARY_MARKERS = {
49
- "imin": "symmetry",
50
- "imax": "outflow",
51
- "jmin": "wall",
52
- "jmax": "farfield",
55
+ # grid side of each [markers] key (the names themselves come from the config): imin is
56
+ # the stagnation line ahead of the nose, imax the outflow at x_end, jmin the wall,
57
+ # jmax the outer boundary
58
+ MARKER_SIDES = {
59
+ "symmetry": "imin",
60
+ "outflow": "imax",
61
+ "wall": "jmin",
62
+ "outer": "jmax",
53
63
  }
54
64
 
55
- # boundary names for the two extra sides of an extruded (3D) grid: the first and last
56
- # z-planes; the solver decides what they are (periodic, symmetry, ...)
57
- SPAN_MARKERS = {
58
- "kmin": "span_min",
59
- "kmax": "span_max",
65
+ # the two extra sides of an extruded (3D) grid: the first and last z-planes; the
66
+ # solver decides what they are (periodic, symmetry, ...)
67
+ SPAN_MARKER_SIDES = {
68
+ "span_min": "kmin",
69
+ "span_max": "kmax",
60
70
  }
61
71
 
62
72
 
@@ -88,16 +98,12 @@ def generate_body_fitted(source: BodyFittedConfig | str | Path) -> Dataset:
88
98
  # arc lengths of the wall points: nose, then body
89
99
  s_wall = wall_arc_lengths(profile, config.points.nose.n_points, config.body_distribution())
90
100
 
91
- # wall-normal distances: from the wall up to the height
92
- n_dist = build_distribution(config.normal_distribution())
93
-
94
101
  # exact wall points and normals
95
102
  x_wall, y_wall = profile.point(s_wall)
96
103
  nx, ny = profile.normal(s_wall)
97
104
 
98
- # j-lines straight out along the wall normals: (ni, nj)
99
- x_plane = x_wall[:, None] + n_dist[None, :] * nx[:, None]
100
- y_plane = y_wall[:, None] + n_dist[None, :] * ny[:, None]
105
+ # j-lines from the wall out to the outer boundary: (ni, nj)
106
+ x_plane, y_plane = build_grid_lines(config, x_wall, y_wall, nx, ny)
101
107
 
102
108
  # span: the z-planes to extrude to, or the single plane z = 0 for a 2D grid
103
109
  if config.span is not None:
@@ -112,10 +118,13 @@ def generate_body_fitted(source: BodyFittedConfig | str | Path) -> Dataset:
112
118
  z = np.broadcast_to(z_axis[None, None, :], x.shape).copy()
113
119
  grid = StructuredGrid(x=x, y=y, z=z)
114
120
 
115
- # markers: four sides in 2D, plus the two z-planes in 3D
116
- markers = dict(BOUNDARY_MARKERS)
121
+ # markers: four sides in 2D, plus the two z-planes in 3D, named by [markers]
122
+ markers = {}
123
+ for key, side in MARKER_SIDES.items():
124
+ markers[side] = getattr(config.markers, key)
117
125
  if nk > 1:
118
- markers.update(SPAN_MARKERS)
126
+ for key, side in SPAN_MARKER_SIDES.items():
127
+ markers[side] = getattr(config.markers, key)
119
128
  dimensionality = 3 if nk > 1 else 2
120
129
 
121
130
  # describe the grid; markers are what cfd-io's SU2 writer needs
@@ -139,6 +148,88 @@ def generate_body_fitted(source: BodyFittedConfig | str | Path) -> Dataset:
139
148
  return dataset
140
149
 
141
150
 
151
+ def build_grid_lines(
152
+ config: BodyFittedConfig,
153
+ x_wall: NDArray[np.float64],
154
+ y_wall: NDArray[np.float64],
155
+ nx: NDArray[np.float64],
156
+ ny: NDArray[np.float64],
157
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
158
+ """The j-lines: from each wall point out to the outer boundary.
159
+
160
+ Args:
161
+ config: Validated configuration.
162
+ x_wall: Wall points x [m].
163
+ y_wall: Wall points y [m].
164
+ nx: Unit wall normals, x component.
165
+ ny: Unit wall normals, y component.
166
+
167
+ Returns:
168
+ Grid x and y [m], shape (ni, nj).
169
+
170
+ Raises:
171
+ ValueError: If a wall normal misses the outer boundary, or [points.normal]
172
+ doesn't give every line the same number of points.
173
+ """
174
+
175
+ # constant height: straight lines along the wall normals, all with the same distribution
176
+ if config.outer is None:
177
+ n_line = build_distribution(config.normal_distribution())
178
+ x_plane = x_wall[:, None] + n_line[None, :] * nx[:, None]
179
+ y_plane = y_wall[:, None] + n_line[None, :] * ny[:, None]
180
+ return x_plane, y_plane
181
+
182
+ # shock-aligned: the outer end of every line, on a smooth curve beyond the shock
183
+ shock_x, shock_y = read_shock_curve(config.outer.shock)
184
+ outer_x, outer_y = outer_points(
185
+ x_wall,
186
+ y_wall,
187
+ nx,
188
+ ny,
189
+ shock_x,
190
+ shock_y,
191
+ config.outer.margin_factor,
192
+ config.outer.min_offset,
193
+ )
194
+
195
+ # each line: along the normal at the wall, bending toward its outer point
196
+ lines_x = []
197
+ lines_y = []
198
+ for i in range(x_wall.size):
199
+ # split the reach into a part along the normal and a shift across it
200
+ reach_x = outer_x[i] - x_wall[i]
201
+ reach_y = outer_y[i] - y_wall[i]
202
+ height = reach_x * nx[i] + reach_y * ny[i]
203
+ shift_x = reach_x - height * nx[i]
204
+ shift_y = reach_y - height * ny[i]
205
+
206
+ # the same normal distribution (first spacing, points) stretched to this height
207
+ try:
208
+ n_line = build_distribution(config.normal_distribution(float(height)))
209
+ except ValueError as error:
210
+ raise ValueError(
211
+ f"[points.normal] at a line height of {height:.4g} m: {error}"
212
+ ) from None
213
+
214
+ # the shift grows quadratically, so the line leaves the wall along the normal
215
+ fraction = n_line / height
216
+ lines_x.append(x_wall[i] + n_line * nx[i] + fraction**2 * shift_x)
217
+ lines_y.append(y_wall[i] + n_line * ny[i] + fraction**2 * shift_y)
218
+
219
+ # check: a structured grid needs the same number of points on every line
220
+ counts = {line.size for line in lines_x}
221
+ if len(counts) > 1:
222
+ raise ValueError(
223
+ "[points.normal] gives the lines different numbers of points "
224
+ f"({min(counts)} to {max(counts)}); set n_points for a shock-aligned grid"
225
+ )
226
+
227
+ x_plane = np.vstack(lines_x)
228
+ y_plane = np.vstack(lines_y)
229
+
230
+ return x_plane, y_plane
231
+
232
+
142
233
  def wall_arc_lengths(
143
234
  profile: Profile2D, n_nose: int, body_spec: DistributionSpec
144
235
  ) -> NDArray[np.float64]:
@@ -11,8 +11,22 @@ import numpy as np
11
11
  from cfd_io import Dataset, StructuredGrid
12
12
 
13
13
  from grid_generator.config import CartesianConfig, load_cartesian_config
14
+ from grid_generator.config.cartesian import AXIS_MARKER_KEYS
14
15
  from grid_generator.distributions import build_distribution
15
16
 
17
+ # --------------------------------------------------
18
+ # constants
19
+ # --------------------------------------------------
20
+ # grid side of each [markers] key (the names themselves come from the config)
21
+ MARKER_SIDES = {
22
+ "x_min": "imin",
23
+ "x_max": "imax",
24
+ "y_min": "jmin",
25
+ "y_max": "jmax",
26
+ "z_min": "kmin",
27
+ "z_max": "kmax",
28
+ }
29
+
16
30
 
17
31
  # --------------------------------------------------
18
32
  # public API
@@ -24,7 +38,8 @@ def generate_cartesian(source: CartesianConfig | str | Path) -> Dataset:
24
38
  source: Validated configuration or configuration file path.
25
39
 
26
40
  Returns:
27
- Grid-only CFD dataset with physical coordinate arrays.
41
+ Grid-only CFD dataset with physical coordinate arrays and the boundary markers
42
+ of the axes the grid has.
28
43
  """
29
44
 
30
45
  # load configuration when the caller supplied an artifact path
@@ -42,6 +57,15 @@ def generate_cartesian(source: CartesianConfig | str | Path) -> Dataset:
42
57
  grid_x, grid_y, grid_z = np.meshgrid(x_axis, y_axis, z_axis, indexing="ij")
43
58
  grid = StructuredGrid(x=grid_x, y=grid_y, z=grid_z)
44
59
 
60
+ # markers: both ends of each axis the grid has, named by [markers]
61
+ active_axes = {"x": config.x, "y": config.y, "z": config.z}
62
+ markers = {}
63
+ for axis_name, axis_spec in active_axes.items():
64
+ if axis_spec is None:
65
+ continue
66
+ for key in AXIS_MARKER_KEYS[axis_name]:
67
+ markers[MARKER_SIDES[key]] = getattr(config.markers, key)
68
+
45
69
  dimensionality = 1 + int(config.y is not None) + int(config.z is not None)
46
70
  dataset = Dataset(
47
71
  grid=grid,
@@ -54,6 +78,7 @@ def generate_cartesian(source: CartesianConfig | str | Path) -> Dataset:
54
78
  "ni": grid.shape[0],
55
79
  "nj": grid.shape[1],
56
80
  "nk": grid.shape[2],
81
+ "markers": {"structured": markers},
57
82
  },
58
83
  )
59
84
  return dataset
@@ -7,7 +7,7 @@ from typing import Annotated
7
7
 
8
8
  import typer
9
9
 
10
- from grid_generator.cli.callbacks import version_callback
10
+ from grid_generator.cli.callbacks import verbose_callback, version_callback
11
11
  from grid_generator.cli.cmd_cartesian import cartesian_app
12
12
  from grid_generator.cli.cmd_distribution import distribution_app
13
13
  from grid_generator.cli.cmd_geometry import GEOMETRY_HELP, build_geometry_app
@@ -38,20 +38,31 @@ def main(
38
38
  help="Show version and exit.",
39
39
  ),
40
40
  ] = False,
41
+ verbose: Annotated[
42
+ bool,
43
+ typer.Option(
44
+ "--verbose",
45
+ "-v",
46
+ callback=verbose_callback,
47
+ help="Enable verbose (debug) output.",
48
+ ),
49
+ ] = False,
41
50
  ) -> None:
42
51
  """Generate structured computational grids."""
43
52
 
44
- del version
53
+ del version, verbose
45
54
 
46
55
 
47
56
  # --------------------------------------------------
48
- # register subcommands
57
+ # register subcommands (note: order of registration is order of appearance in the CLI help)
49
58
  # --------------------------------------------------
59
+
60
+ # 1D point distributions: reference (info) and 1D point files (init / run)
61
+ app.add_typer(distribution_app, name="distribution")
62
+
63
+ # Cartesian grids: structured grids aligned with the coordinate axes
50
64
  app.add_typer(cartesian_app, name="cartesian")
51
65
 
52
- # body-fitted grids, one command group per geometry: plate, wedge, cone
66
+ # body-fitted grids, one command group per geometry: plate, wedge, cone, ogive, ... (add more as needed)
53
67
  for geometry_type in GEOMETRY_HELP:
54
68
  app.add_typer(build_geometry_app(geometry_type), name=geometry_type)
55
-
56
- # 1D point distributions: reference (info) and 1D point files (init / run)
57
- app.add_typer(distribution_app, name="distribution")
@@ -0,0 +1,47 @@
1
+ """Shared callbacks for the grid-generator CLI."""
2
+
3
+ # --------------------------------------------------
4
+ # load necessary modules
5
+ # --------------------------------------------------
6
+ import logging
7
+
8
+ import typer
9
+
10
+ from grid_generator import __version__
11
+
12
+
13
+ # --------------------------------------------------
14
+ # callbacks
15
+ # --------------------------------------------------
16
+ def version_callback(value: bool) -> None:
17
+ """Print the installed package version and exit."""
18
+
19
+ if value:
20
+ typer.echo(f"grid-generator {__version__}")
21
+ raise typer.Exit()
22
+
23
+
24
+ # --------------------------------------------------
25
+ # verbose callback
26
+ # --------------------------------------------------
27
+
28
+ # loggers that --verbose switches to debug: this tool and the uahypersonics
29
+ # packages it calls
30
+ DEBUG_LOGGERS = ("grid_generator", "canonical_geometries", "cfd_io")
31
+
32
+
33
+ def verbose_callback(value: bool) -> None:
34
+ """Configure console logging; --verbose adds debug output from our own packages.
35
+
36
+ By default only warnings are shown. With --verbose, this tool and the
37
+ packages it calls (e.g. cfd-io writing a grid) log at debug level, while
38
+ third-party libraries (h5py, ...) stay quiet.
39
+ """
40
+
41
+ # default console logging for every logger: warnings only
42
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(message)s")
43
+
44
+ # debug output from this tool and the uahypersonics packages it calls
45
+ if value:
46
+ for logger_name in DEBUG_LOGGERS:
47
+ logging.getLogger(logger_name).setLevel(logging.DEBUG)
@@ -12,6 +12,7 @@ from typing import Annotated
12
12
  import typer
13
13
 
14
14
  from grid_generator.body_fitted import generate_body_fitted
15
+ from grid_generator.cli.examples_command import build_examples_command, list_examples
15
16
  from grid_generator.cli.help_text import format_examples
16
17
  from grid_generator.config import (
17
18
  BODY_BLOCKS,
@@ -187,4 +188,8 @@ def build_geometry_app(geometry_type: str) -> typer.Typer:
187
188
  typer.echo(quality.summary())
188
189
  typer.echo(f"Wrote: {output_path}")
189
190
 
191
+ # examples: bundled cases, copied into ./<name>/ to run (only types that ship some)
192
+ if list_examples(geometry_type):
193
+ geometry_app.command("examples")(build_examples_command(geometry_type))
194
+
190
195
  return geometry_app
@@ -0,0 +1,179 @@
1
+ """Bundled example cases and the `grid-generator <type> examples` command.
2
+
3
+ Each example is a folder under ``grid_generator/examples/<type>/`` holding the
4
+ files of one case, with the config named like run's default (e.g. ``cone.toml``).
5
+ The folder name is the example's name and the first text line of its config is
6
+ the description, so the folders are the registry: adding an example means
7
+ adding a folder.
8
+ """
9
+
10
+ # --------------------------------------------------
11
+ # load necessary modules
12
+ # --------------------------------------------------
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from importlib.resources import files
17
+ from importlib.resources.abc import Traversable
18
+ from pathlib import Path
19
+ from typing import Annotated
20
+
21
+ import typer
22
+
23
+
24
+ # --------------------------------------------------
25
+ # helpers
26
+ # --------------------------------------------------
27
+ def _type_root(geometry_type: str) -> Traversable:
28
+ """Folder of one geometry type's examples inside the installed package."""
29
+ return files("grid_generator") / "examples" / geometry_type
30
+
31
+
32
+ def _description(config_text: str) -> str:
33
+ """First text line of a config's header comment (dividers skipped)."""
34
+
35
+ # walk the leading comment block
36
+ for line in config_text.splitlines():
37
+ stripped = line.strip()
38
+
39
+ # the header ends at the first line that isn't a comment
40
+ if stripped and not stripped.startswith("#"):
41
+ break
42
+
43
+ # skip blank comment lines and dividers like "# -----"
44
+ text = stripped.lstrip("#").strip()
45
+ if text and set(text) - set("-=#"):
46
+ return text
47
+
48
+ return ""
49
+
50
+
51
+ # --------------------------------------------------
52
+ # public API
53
+ # --------------------------------------------------
54
+ def list_examples(geometry_type: str) -> dict[str, str]:
55
+ """Return a name -> description mapping of one geometry type's examples.
56
+
57
+ Args:
58
+ geometry_type: Geometry type, e.g. "cone".
59
+
60
+ Returns:
61
+ Dict of {name: description}, sorted by name (empty without examples).
62
+ """
63
+
64
+ # a type without examples has no folder
65
+ root = _type_root(geometry_type)
66
+ if not root.is_dir():
67
+ return {}
68
+
69
+ # every folder with the type's config is an example
70
+ examples: dict[str, str] = {}
71
+ config_name = f"{geometry_type}.toml"
72
+ for folder in sorted(root.iterdir(), key=lambda item: item.name):
73
+ config = folder / config_name
74
+ if folder.is_dir() and config.is_file():
75
+ examples[folder.name] = _description(config.read_text(encoding="utf-8"))
76
+
77
+ return examples
78
+
79
+
80
+ def copy_example(
81
+ geometry_type: str, name: str, destination: str | Path, force: bool = False
82
+ ) -> list[Path]:
83
+ """Copy one example's files into a folder.
84
+
85
+ Args:
86
+ geometry_type: Geometry type, e.g. "cone".
87
+ name: Example name (see list_examples).
88
+ destination: Folder to create and copy the files into.
89
+ force: Overwrite files that already exist in the folder.
90
+
91
+ Returns:
92
+ The paths of the written files.
93
+
94
+ Raises:
95
+ KeyError: If the type has no example of that name.
96
+ FileExistsError: If the folder exists and force is False.
97
+ """
98
+
99
+ # check: a known example
100
+ if name not in list_examples(geometry_type):
101
+ raise KeyError(name)
102
+
103
+ # check: don't overwrite an earlier copy unless asked to
104
+ destination = Path(destination)
105
+ if destination.exists() and not force:
106
+ raise FileExistsError(f"{destination} already exists (use --force to overwrite)")
107
+
108
+ # copy every file of the example folder
109
+ destination.mkdir(parents=True, exist_ok=True)
110
+ written: list[Path] = []
111
+ source = _type_root(geometry_type) / name
112
+ for item in sorted(source.iterdir(), key=lambda entry: entry.name):
113
+ if item.is_file():
114
+ target = destination / item.name
115
+ target.write_bytes(item.read_bytes())
116
+ written.append(target)
117
+
118
+ return written
119
+
120
+
121
+ def build_examples_command(geometry_type: str) -> Callable[..., None]:
122
+ """Build the `examples` subcommand of one geometry type.
123
+
124
+ Args:
125
+ geometry_type: Geometry type, e.g. "cone" (also the examples folder).
126
+
127
+ Returns:
128
+ The command function to register as ``<geometry_app>.command("examples")``.
129
+ """
130
+
131
+ def cmd_examples(
132
+ name: Annotated[
133
+ str | None,
134
+ typer.Argument(help="Example to copy into ./<name>/. Omit to list the examples."),
135
+ ] = None,
136
+ force: Annotated[
137
+ bool,
138
+ typer.Option("--force", "-f", help="Overwrite an existing ./<name>/ folder."),
139
+ ] = False,
140
+ ) -> None:
141
+ """List the bundled examples, or copy one into a new folder to run."""
142
+
143
+ # list: name and description of every example
144
+ examples = list_examples(geometry_type)
145
+ if name is None:
146
+ width = max(len(example_name) for example_name in [*examples, "<name>"])
147
+ typer.echo("")
148
+ typer.echo(f" {'<name>':<{width}} description")
149
+ typer.echo(f" {'-' * width} {'-' * 11}")
150
+ for example_name, description in examples.items():
151
+ typer.echo(f" {example_name:<{width}} {description}")
152
+ typer.echo("")
153
+ typer.echo("command sequence:")
154
+ typer.echo(" - check out the example")
155
+ typer.echo(f" grid-generator {geometry_type} examples <name>")
156
+ typer.echo(" - change into its folder")
157
+ typer.echo(" cd <name>")
158
+ typer.echo(" - run the example")
159
+ typer.echo(f" grid-generator {geometry_type} run")
160
+ typer.echo("")
161
+ return
162
+
163
+ # copy the example into ./<name>/
164
+ try:
165
+ written = copy_example(geometry_type, name, Path(name), force=force)
166
+ except KeyError:
167
+ known = ", ".join(examples)
168
+ typer.echo(f"error: unknown example {name!r}; available: {known}", err=True)
169
+ raise typer.Exit(1) from None
170
+ except FileExistsError as error:
171
+ typer.echo(f"error: {error}", err=True)
172
+ raise typer.Exit(1) from None
173
+
174
+ # report the files and the next step
175
+ for path in written:
176
+ typer.echo(f"wrote {path}")
177
+ typer.echo(f"then: cd {name} && grid-generator {geometry_type} run")
178
+
179
+ return cmd_examples