spin 0.8__tar.gz → 0.9__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.
@@ -1,6 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
- Copyright (c) 2021--2022, Scientific Python project
3
+ Copyright (c) 2021--2024, Scientific Python project
4
4
  All rights reserved.
5
5
 
6
6
  Redistribution and use in source and binary forms, with or without
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spin
3
- Version: 0.8
3
+ Version: 0.9
4
4
  Summary: Developer tool for scientific Python libraries
5
5
  Maintainer-email: Scientific Python <spin@discuss.scientific-python.org>
6
6
  License: BSD 3-Clause License
7
7
 
8
- Copyright (c) 2021--2022, Scientific Python project
8
+ Copyright (c) 2021--2024, Scientific Python project
9
9
  All rights reserved.
10
10
 
11
11
  Redistribution and use in source and binary forms, with or without
@@ -53,24 +53,28 @@ License-File: LICENSE
53
53
  Requires-Dist: click
54
54
  Requires-Dist: tomli; python_version < "3.11"
55
55
  Requires-Dist: colorama; platform_system == "Windows"
56
+ Requires-Dist: importlib_metadata>=7
56
57
  Provides-Extra: lint
57
- Requires-Dist: pre-commit==3.5.0; extra == "lint"
58
+ Requires-Dist: pre-commit==3.7.0; extra == "lint"
58
59
  Provides-Extra: dev
59
- Requires-Dist: changelist==0.4; extra == "dev"
60
+ Requires-Dist: changelist==0.5; extra == "dev"
60
61
 
61
62
  # 💫 Scientific Python INcantations (`spin`)
62
63
 
63
64
  ## A developer tool for scientific Python libraries
64
65
 
65
66
  Developers need to memorize a whole bunch of magic command-line incantations.
66
- And these incantations change from time to time!
67
- Typically, their lives are made simpler by a Makefile, but Makefiles can be convoluted, are not written in Python, and are hard to extend.
68
- The rationale behind `spin` is therefore to provide a simple interface for common development tasks.
67
+ These incantations may also change over time.
68
+ Often, Makefiles are used to provide aliases, but Makefiles can be convoluted, are not written in Python, and are hard to extend.
69
+ The goal of `spin` is therefore to provide a simple, user-friendly, extendable interface for common development tasks.
69
70
  It comes with a few common build commands out the box, but can easily be customized per project.
70
71
 
71
72
  As a curiosity: the impetus behind developing the tool was the mass migration of scientific Python libraries (SciPy, scikit-image, and NumPy, etc.) to Meson, after distutils was deprecated.
72
73
  When many of the build and installation commands changed, it made sense to abstract away the nuisance of having to re-learn them.
73
74
 
75
+ _Note:_ We now have experimental builds for editable installs.
76
+ Most of the Meson commands listed below should work "out of the box" for those.
77
+
74
78
  ## Installation
75
79
 
76
80
  ```
@@ -152,7 +156,6 @@ Available as `spin.cmds.meson.*`.
152
156
  docs 📖 Build Sphinx documentation
153
157
  gdb 👾 Execute a Python snippet with GDB
154
158
  lldb 👾 Execute a Python snippet with LLDB
155
- install 💽 Build and install package using pip.
156
159
  ```
157
160
 
158
161
  ### [Build](https://pypa-build.readthedocs.io/en/stable/) (PEP 517 builder)
@@ -207,6 +210,29 @@ def example():
207
210
  print(config["tool.spin"])
208
211
  ```
209
212
 
213
+ ### Argument overrides
214
+
215
+ Default arguments can be overridden for any command.
216
+ The custom command above, e.g., has the following signature:
217
+
218
+ ```python
219
+ @click.command()
220
+ @click.option("-f", "--flag")
221
+ @click.option("-t", "--test", default="not set")
222
+ def example(flag, test, default_kwd=None):
223
+ """🧪 Example custom command.
224
+ ...
225
+ """
226
+ ```
227
+
228
+ Use the `[tool.spin.kwargs]` section to override default values for
229
+ click options or function keywords:
230
+
231
+ ```toml
232
+ [tool.spin.kwargs]
233
+ ".spin/cmds.py:example" = {"test" = "default override", "default_kwd" = 3}
234
+ ```
235
+
210
236
  ### Advanced: adding arguments to built-in commands
211
237
 
212
238
  Instead of rewriting a command from scratch, a project may want to add a flag to a built-in `spin` command, or perhaps do some pre- or post-processing.
@@ -284,7 +310,7 @@ fc-cache -f -v
284
310
  `spin` tests are invoked using:
285
311
 
286
312
  ```
287
- nox -s tests
313
+ nox -s test
288
314
  ```
289
315
 
290
316
  ## History
@@ -3,14 +3,17 @@
3
3
  ## A developer tool for scientific Python libraries
4
4
 
5
5
  Developers need to memorize a whole bunch of magic command-line incantations.
6
- And these incantations change from time to time!
7
- Typically, their lives are made simpler by a Makefile, but Makefiles can be convoluted, are not written in Python, and are hard to extend.
8
- The rationale behind `spin` is therefore to provide a simple interface for common development tasks.
6
+ These incantations may also change over time.
7
+ Often, Makefiles are used to provide aliases, but Makefiles can be convoluted, are not written in Python, and are hard to extend.
8
+ The goal of `spin` is therefore to provide a simple, user-friendly, extendable interface for common development tasks.
9
9
  It comes with a few common build commands out the box, but can easily be customized per project.
10
10
 
11
11
  As a curiosity: the impetus behind developing the tool was the mass migration of scientific Python libraries (SciPy, scikit-image, and NumPy, etc.) to Meson, after distutils was deprecated.
12
12
  When many of the build and installation commands changed, it made sense to abstract away the nuisance of having to re-learn them.
13
13
 
14
+ _Note:_ We now have experimental builds for editable installs.
15
+ Most of the Meson commands listed below should work "out of the box" for those.
16
+
14
17
  ## Installation
15
18
 
16
19
  ```
@@ -92,7 +95,6 @@ Available as `spin.cmds.meson.*`.
92
95
  docs 📖 Build Sphinx documentation
93
96
  gdb 👾 Execute a Python snippet with GDB
94
97
  lldb 👾 Execute a Python snippet with LLDB
95
- install 💽 Build and install package using pip.
96
98
  ```
97
99
 
98
100
  ### [Build](https://pypa-build.readthedocs.io/en/stable/) (PEP 517 builder)
@@ -147,6 +149,29 @@ def example():
147
149
  print(config["tool.spin"])
148
150
  ```
149
151
 
152
+ ### Argument overrides
153
+
154
+ Default arguments can be overridden for any command.
155
+ The custom command above, e.g., has the following signature:
156
+
157
+ ```python
158
+ @click.command()
159
+ @click.option("-f", "--flag")
160
+ @click.option("-t", "--test", default="not set")
161
+ def example(flag, test, default_kwd=None):
162
+ """🧪 Example custom command.
163
+ ...
164
+ """
165
+ ```
166
+
167
+ Use the `[tool.spin.kwargs]` section to override default values for
168
+ click options or function keywords:
169
+
170
+ ```toml
171
+ [tool.spin.kwargs]
172
+ ".spin/cmds.py:example" = {"test" = "default override", "default_kwd" = 3}
173
+ ```
174
+
150
175
  ### Advanced: adding arguments to built-in commands
151
176
 
152
177
  Instead of rewriting a command from scratch, a project may want to add a flag to a built-in `spin` command, or perhaps do some pre- or post-processing.
@@ -224,7 +249,7 @@ fc-cache -f -v
224
249
  `spin` tests are invoked using:
225
250
 
226
251
  ```
227
- nox -s tests
252
+ nox -s test
228
253
  ```
229
254
 
230
255
  ## History
@@ -25,7 +25,8 @@ classifiers = [
25
25
  dependencies = [
26
26
  "click",
27
27
  "tomli; python_version < '3.11'",
28
- "colorama; platform_system == 'Windows'"
28
+ "colorama; platform_system == 'Windows'",
29
+ "importlib_metadata >= 7"
29
30
  ]
30
31
  dynamic = ['version']
31
32
 
@@ -33,8 +34,8 @@ dynamic = ['version']
33
34
  spin = "spin.__main__:main"
34
35
 
35
36
  [project.optional-dependencies]
36
- lint = ["pre-commit == 3.5.0"]
37
- dev = ["changelist == 0.4"]
37
+ lint = ["pre-commit == 3.7.0"]
38
+ dev = ["changelist == 0.5"]
38
39
 
39
40
  [project.urls]
40
41
  homepage = "https://github.com/scientific-python/spin"
@@ -6,7 +6,7 @@ For usage information see `python -m spin`.
6
6
  Read more at https://github.com/scientific-python/spin
7
7
  """
8
8
 
9
- __version__ = "0.8"
9
+ __version__ = "0.9"
10
10
 
11
11
  from .cmds import util
12
12
 
@@ -25,10 +25,6 @@ def main():
25
25
  if (len(sys.argv) == 2) and (sys.argv[1] == "help"):
26
26
  sys.argv[1] = "--help"
27
27
 
28
- def error(message):
29
- print(f"Error: {message}", file=sys.stderr)
30
- sys.exit(1)
31
-
32
28
  def load_toml(filename):
33
29
  if not os.path.exists(filename):
34
30
  return None
@@ -36,7 +32,7 @@ def main():
36
32
  try:
37
33
  return tomllib.load(f)
38
34
  except tomllib.TOMLDecodeError:
39
- error("cannot parse [{filename}]")
35
+ print(f"Error: cannot parse [{filename}]", file=sys.stderr)
40
36
 
41
37
  toml_config = collections.ChainMap()
42
38
  toml_config.maps.extend(
@@ -50,18 +46,28 @@ def main():
50
46
  )
51
47
 
52
48
  # Basic configuration validation
53
- if "tool.spin" not in toml_config:
54
- error(
55
- "needs valid configuration in [.spin.toml], [spin.toml] or [pyproject.toml]"
56
- )
57
- if "tool.spin.commands" not in toml_config:
58
- error("configuration is missing section [tool.spin.commands]")
49
+ version_query = len(sys.argv) == 2 and (sys.argv[1] == "--version")
50
+
51
+ spin_config = {}
52
+ if not version_query:
53
+ if "tool.spin" in toml_config:
54
+ spin_config = toml_config["tool.spin"]
55
+ if "tool.spin.commands" not in toml_config:
56
+ print(
57
+ "Error: configuration is missing section [tool.spin.commands]\n",
58
+ file=sys.stderr,
59
+ )
60
+ else:
61
+ print(
62
+ "Error: need valid configuration in [.spin.toml], [spin.toml], or [pyproject.toml]\n"
63
+ "See https://github.com/scientific-python/spin/blob/main/README.md\n",
64
+ file=sys.stderr,
65
+ )
59
66
 
60
- spin_config = toml_config["tool.spin"]
61
67
  proj_name = (
62
68
  toml_config.get("project.name")
63
69
  or spin_config.get("package")
64
- or "unknown project"
70
+ or "[unknown project]"
65
71
  )
66
72
 
67
73
  @click.group(help=f"Developer tool for {proj_name}", cls=SectionedHelpGroup)
@@ -72,7 +78,7 @@ def main():
72
78
  ctx.meta["commands"] = ctx.command.section_commands
73
79
  ctx.show_default = True
74
80
 
75
- config_cmds = spin_config["commands"]
81
+ config_cmds = spin_config.get("commands", [])
76
82
  # Commands can be provided as a list, or as a dictionary
77
83
  # so that they can be sorted into sections
78
84
  if isinstance(config_cmds, list):
@@ -88,6 +94,7 @@ def main():
88
94
  "spin.python": _cmds.meson.python,
89
95
  "spin.shell": _cmds.meson.shell,
90
96
  }
97
+ cmd_default_kwargs = toml_config.get("tool.spin.kwargs", {})
91
98
 
92
99
  for section, cmds in config_cmds.items():
93
100
  for cmd in cmds:
@@ -127,6 +134,18 @@ def main():
127
134
  print(f"!! Could not load command `{func}` from file `{path}`.\n")
128
135
  continue
129
136
 
137
+ default_kwargs = cmd_default_kwargs.get(cmd)
138
+ import functools
139
+
140
+ if default_kwargs:
141
+ callback = cmd_func.callback
142
+ cmd_func.callback = functools.partial(callback, **default_kwargs)
143
+
144
+ # Also override option defaults
145
+ for option in cmd_func.params:
146
+ if option.name in default_kwargs:
147
+ option.default = default_kwargs[option.name]
148
+
130
149
  commands[cmd] = cmd_func
131
150
 
132
151
  group.add_command(commands[cmd], section=section)
@@ -134,7 +153,8 @@ def main():
134
153
  try:
135
154
  group()
136
155
  except Exception as e:
137
- error(f"{e}; aborting.")
156
+ print(f"Error: {e}; aborting.", file=sys.stderr)
157
+ sys.exit(1)
138
158
 
139
159
 
140
160
  if __name__ == "__main__":
@@ -1,8 +1,12 @@
1
1
  import contextlib
2
+ import copy
2
3
  import json
3
4
  import os
5
+ import re
4
6
  import shutil
5
7
  import sys
8
+ from enum import Enum
9
+ from pathlib import Path
6
10
 
7
11
  import click
8
12
 
@@ -10,6 +14,14 @@ from .util import get_commands, get_config
10
14
  from .util import run as _run
11
15
 
12
16
  install_dir = "build-install"
17
+ build_dir = "build"
18
+
19
+
20
+ class GcovReportFormat(str, Enum):
21
+ html = "html"
22
+ xml = "xml"
23
+ text = "text"
24
+ sonarqube = "sonarqube"
13
25
 
14
26
 
15
27
  # Allow specification of meson binary in configuration
@@ -25,6 +37,32 @@ def _meson_cli():
25
37
  return [meson_cli]
26
38
 
27
39
 
40
+ def _editable_install_path(distname):
41
+ import importlib_metadata
42
+
43
+ try:
44
+ dist = importlib_metadata.Distribution.from_name(distname)
45
+ except importlib_metadata.PackageNotFoundError:
46
+ return None
47
+
48
+ if getattr(dist.origin.dir_info, "editable", False):
49
+ if sys.platform == "win32":
50
+ return dist.origin.url.removeprefix("file:///")
51
+ else:
52
+ return dist.origin.url.removeprefix("file://")
53
+ else:
54
+ return None
55
+
56
+
57
+ def _is_editable_install(distname):
58
+ return _editable_install_path(distname) is not None
59
+
60
+
61
+ def _is_editable_install_of_same_source(distname):
62
+ editable_path = _editable_install_path(distname)
63
+ return editable_path and os.path.samefile(_editable_install_path(distname), ".")
64
+
65
+
28
66
  def _set_pythonpath(quiet=False):
29
67
  """Set first entry of PYTHONPATH to site packages directory.
30
68
 
@@ -35,6 +73,29 @@ def _set_pythonpath(quiet=False):
35
73
  site_packages = _get_site_packages()
36
74
  env = os.environ
37
75
 
76
+ cfg = get_config()
77
+ distname = cfg.get("project.name", None)
78
+ if distname:
79
+ if _is_editable_install(distname):
80
+ if _is_editable_install_of_same_source(distname):
81
+ if not (quiet):
82
+ click.secho(
83
+ "Editable install of same source directory detected; not setting PYTHONPATH",
84
+ fg="bright_red",
85
+ )
86
+ return ""
87
+ else:
88
+ # Ignoring the quiet flag, because picking up the wrong package is problematic
89
+ click.secho(
90
+ f"Warning! Editable install of `{distname}`, from a different source location, detected.",
91
+ fg="bright_red",
92
+ )
93
+ click.secho("Spin commands will pick up that version.", fg="bright_red")
94
+ click.secho(
95
+ f"Try removing the other installation by switching to its source and running `pip uninstall {distname}`.",
96
+ fg="bright_red",
97
+ )
98
+
38
99
  if "PYTHONPATH" in env:
39
100
  env["PYTHONPATH"] = f"{site_packages}{os.pathsep}{env['PYTHONPATH']}"
40
101
  else:
@@ -49,6 +110,15 @@ def _set_pythonpath(quiet=False):
49
110
 
50
111
 
51
112
  def _get_site_packages():
113
+ try:
114
+ cfg = get_config()
115
+ distname = cfg.get("project.name", None)
116
+ if _is_editable_install_of_same_source(distname):
117
+ return ""
118
+ except RuntimeError:
119
+ # Probably not running in click
120
+ pass
121
+
52
122
  candidate_paths = []
53
123
  for root, dirs, _files in os.walk(install_dir):
54
124
  for subdir in dirs:
@@ -103,14 +173,67 @@ def _meson_version_configured():
103
173
  pass
104
174
 
105
175
 
176
+ def _meson_coverage_configured() -> bool:
177
+ try:
178
+ build_options_fn = os.path.join(
179
+ "build", "meson-info", "intro-buildoptions.json"
180
+ )
181
+ with open(build_options_fn) as f:
182
+ build_options = json.load(f)
183
+ for b in build_options:
184
+ if (b["name"] == "b_coverage") and (b["value"] is True):
185
+ return True
186
+ except:
187
+ pass
188
+
189
+ return False
190
+
191
+
192
+ def _check_coverage_tool_installation(coverage_type: GcovReportFormat):
193
+ requirements = { # https://github.com/mesonbuild/meson/blob/6e381714c7cb15877e2bcaa304b93c212252ada3/docs/markdown/Unit-tests.md?plain=1#L49-L62
194
+ GcovReportFormat.html: ["Gcovr/GenHTML", "lcov"],
195
+ GcovReportFormat.xml: ["Gcovr (version 3.3 or higher)"],
196
+ GcovReportFormat.text: ["Gcovr (version 3.3 or higher)"],
197
+ GcovReportFormat.sonarqube: ["Gcovr (version 4.2 or higher)"],
198
+ }
199
+
200
+ # First check the presence of a valid build
201
+ if not (os.path.exists(build_dir)):
202
+ raise click.ClickException(
203
+ f"`{build_dir}` folder not found, cannot generate coverage reports. "
204
+ "Generate coverage artefacts by running `spin test --gcov`"
205
+ )
206
+
207
+ debug_files = Path(build_dir).rglob("*.gcno")
208
+ if len(list(debug_files)) == 0:
209
+ raise click.ClickException(
210
+ "Debug build not found, cannot generate coverage reports.\n\n"
211
+ "Please rebuild using `spin build --clean --gcov` first."
212
+ )
213
+
214
+ # Verify the tools are installed prior to the build
215
+ p = _run(["ninja", "-C", build_dir, "-t", "targets", "all"], output=False)
216
+ if f"coverage-{coverage_type.value}" not in p.stdout.decode("ascii"):
217
+ raise click.ClickException(
218
+ f"coverage-{coverage_type.value} is not supported... "
219
+ f"Ensure the following are installed: {', '.join(requirements[coverage_type])} "
220
+ "and rerun `spin test --gcov`"
221
+ )
222
+
223
+
106
224
  @click.command()
107
225
  @click.option("-j", "--jobs", help="Number of parallel tasks to launch", type=int)
108
226
  @click.option("--clean", is_flag=True, help="Clean build directory before build")
109
227
  @click.option(
110
228
  "-v", "--verbose", is_flag=True, help="Print detailed build and installation output"
111
229
  )
230
+ @click.option(
231
+ "--gcov",
232
+ is_flag=True,
233
+ help="Enable C code coverage using `gcov`. Use `spin test --gcov` to generate reports.",
234
+ )
112
235
  @click.argument("meson_args", nargs=-1)
113
- def build(meson_args, jobs=None, clean=False, verbose=False, quiet=False):
236
+ def build(meson_args, jobs=None, clean=False, verbose=False, gcov=False, quiet=False):
114
237
  """🔧 Build package with Meson/ninja and install
115
238
 
116
239
  MESON_ARGS are passed through e.g.:
@@ -128,8 +251,22 @@ def build(meson_args, jobs=None, clean=False, verbose=False, quiet=False):
128
251
 
129
252
  CFLAGS="-O0 -g" spin build
130
253
  """
131
- build_dir = "build"
132
- setup_cmd = _meson_cli() + ["setup", build_dir, "--prefix=/usr"] + list(meson_args)
254
+ cfg = get_config()
255
+ distname = cfg.get("project.name", None)
256
+ if distname and _is_editable_install_of_same_source(distname):
257
+ if not quiet:
258
+ click.secho(
259
+ "Editable install of same source detected; skipping build",
260
+ fg="bright_red",
261
+ )
262
+ return
263
+
264
+ meson_args = list(meson_args)
265
+
266
+ if gcov:
267
+ meson_args = meson_args + ["-Db_coverage=true"]
268
+
269
+ setup_cmd = _meson_cli() + ["setup", build_dir, "--prefix=/usr"] + meson_args
133
270
 
134
271
  if clean:
135
272
  print(f"Removing `{build_dir}`")
@@ -149,7 +286,9 @@ def build(meson_args, jobs=None, clean=False, verbose=False, quiet=False):
149
286
  # Build dir has been configured; check if it was configured by
150
287
  # current version of Meson
151
288
 
152
- if _meson_version() != _meson_version_configured():
289
+ if (_meson_version() != _meson_version_configured()) or (
290
+ gcov and not _meson_coverage_configured()
291
+ ):
153
292
  _run(setup_cmd + ["--reconfigure"], output=not quiet)
154
293
 
155
294
  # Any other conditions that warrant a reconfigure?
@@ -213,10 +352,30 @@ Which tests to run. Can be a module, function, class, or method:
213
352
  "-c",
214
353
  "--coverage",
215
354
  is_flag=True,
216
- help="Generate a coverage report of executed tests. An HTML copy of the report is written to `build/coverage`.",
355
+ help="Generate a Python coverage report of executed tests. An HTML copy of the report is written to `build/coverage`.",
356
+ )
357
+ @click.option(
358
+ "--gcov",
359
+ is_flag=True,
360
+ help="Generate a C coverage report in `build/meson-logs/coveragereport`.",
361
+ )
362
+ @click.option(
363
+ "--gcov-format",
364
+ type=click.Choice(GcovReportFormat),
365
+ default="html",
366
+ help=f"Format of the gcov report. Can be one of {', '.join(e.value for e in GcovReportFormat)}.",
217
367
  )
218
368
  @click.pass_context
219
- def test(ctx, pytest_args, n_jobs, tests, verbose, coverage=False):
369
+ def test(
370
+ ctx,
371
+ pytest_args,
372
+ n_jobs,
373
+ tests,
374
+ verbose,
375
+ coverage=False,
376
+ gcov=None,
377
+ gcov_format=None,
378
+ ):
220
379
  """🔧 Run tests
221
380
 
222
381
  PYTEST_ARGS are passed through directly to pytest, e.g.:
@@ -253,16 +412,28 @@ def test(ctx, pytest_args, n_jobs, tests, verbose, coverage=False):
253
412
 
254
413
  spin test -j auto
255
414
 
415
+ To run a test inside of LLDB:
416
+
417
+ spin lldb -- python -mspin test ...
418
+
256
419
  For more, see `pytest --help`.
257
420
  """ # noqa: E501
258
421
  cfg = get_config()
422
+ distname = cfg.get("project.name", None)
423
+
424
+ if gcov and distname and _is_editable_install_of_same_source(distname):
425
+ click.secho(
426
+ "Error: cannot generate coverage report for editable installs",
427
+ fg="bright_red",
428
+ )
429
+ raise SystemExit(-1)
259
430
 
260
431
  build_cmd = _get_configured_command("build")
261
432
  if build_cmd:
262
433
  click.secho(
263
434
  "Invoking `build` prior to running tests:", bold=True, fg="bright_green"
264
435
  )
265
- ctx.invoke(build_cmd)
436
+ ctx.invoke(build_cmd, gcov=bool(gcov))
266
437
 
267
438
  package = cfg.get("tool.spin.package", None)
268
439
  if (not pytest_args) and (not tests):
@@ -274,6 +445,8 @@ def test(ctx, pytest_args, n_jobs, tests, verbose, coverage=False):
274
445
  sys.exit(1)
275
446
 
276
447
  site_path = _set_pythonpath()
448
+ if site_path:
449
+ print(f'$ export PYTHONPATH="{site_path}"')
277
450
 
278
451
  # Sanity check that library built properly
279
452
  #
@@ -313,12 +486,54 @@ def test(ctx, pytest_args, n_jobs, tests, verbose, coverage=False):
313
486
  f"--cov={package}",
314
487
  ]
315
488
 
316
- print(f'$ export PYTHONPATH="{site_path}"')
317
- _run(
318
- [sys.executable, "-m", "pytest", f"--rootdir={site_path}"] + list(pytest_args),
489
+ if sys.version_info[:2] >= (3, 11):
490
+ cmd = [sys.executable, "-P", "-m", "pytest"]
491
+ else:
492
+ cmd = ["pytest"]
493
+
494
+ cwd = os.getcwd()
495
+ pytest_p = _run(
496
+ cmd + ([f"--rootdir={site_path}"] if site_path else []) + list(pytest_args),
319
497
  cwd=site_path,
320
- replace=True,
321
498
  )
499
+ os.chdir(cwd)
500
+
501
+ if gcov:
502
+ # Verify the tools are present
503
+ click.secho(
504
+ "Verifying gcov dependencies...",
505
+ bold=True,
506
+ fg="bright_yellow",
507
+ )
508
+ _check_coverage_tool_installation(gcov_format)
509
+
510
+ # Generate report
511
+ click.secho(
512
+ f"Generating {gcov_format.value} coverage report...",
513
+ bold=True,
514
+ fg="bright_yellow",
515
+ )
516
+ p = _run(
517
+ [
518
+ "ninja",
519
+ "-C",
520
+ build_dir,
521
+ f"coverage-{gcov_format.value.lower()}",
522
+ ],
523
+ output=False,
524
+ )
525
+ coverage_folder = click.style(
526
+ re.search(r"file://(.*)", p.stdout.decode("utf-8")).group(1),
527
+ bold=True,
528
+ fg="bright_yellow",
529
+ )
530
+ click.secho(
531
+ f"Coverage report generated successfully and written to {coverage_folder}",
532
+ bold=True,
533
+ fg="bright_green",
534
+ )
535
+
536
+ raise SystemExit(pytest_p.returncode)
322
537
 
323
538
 
324
539
  @click.command()
@@ -392,7 +607,8 @@ def ipython(ctx, ipython_args):
392
607
  ctx.invoke(build_cmd)
393
608
 
394
609
  p = _set_pythonpath()
395
- print(f'💻 Launching IPython with PYTHONPATH="{p}"')
610
+ if p:
611
+ print(f'💻 Launching IPython with PYTHONPATH="{p}"')
396
612
  _run(["ipython", "--ignore-cwd"] + list(ipython_args), replace=True)
397
613
 
398
614
 
@@ -417,9 +633,11 @@ def shell(ctx, shell_args=[]):
417
633
  ctx.invoke(build_cmd)
418
634
 
419
635
  p = _set_pythonpath()
636
+ if p:
637
+ print(f'💻 Launching shell with PYTHONPATH="{p}"')
638
+
420
639
  shell = os.environ.get("SHELL", "sh")
421
640
  cmd = [shell] + list(shell_args)
422
- print(f'💻 Launching shell with PYTHONPATH="{p}"')
423
641
  print("⚠ Change directory to avoid importing source instead of built package")
424
642
  print("⚠ Ensure that your ~/.shellrc does not unset PYTHONPATH")
425
643
  _run(cmd, replace=True)
@@ -443,6 +661,9 @@ def python(ctx, python_args):
443
661
  ctx.invoke(build_cmd)
444
662
 
445
663
  p = _set_pythonpath()
664
+ if p:
665
+ print(f'🐍 Launching Python with PYTHONPATH="{p}"')
666
+
446
667
  v = sys.version_info
447
668
  if (v.major < 3) or (v.major == 3 and v.minor < 11):
448
669
  print("We're sorry, but this feature only works on Python 3.11 and greater 😢")
@@ -460,9 +681,7 @@ def python(ctx, python_args):
460
681
  print("import sys; del(sys.path[0])")
461
682
  sys.exit(-1)
462
683
 
463
- print(f'🐍 Launching Python with PYTHONPATH="{p}"')
464
-
465
- _run(["/usr/bin/env", "python", "-P"] + list(python_args), replace=True)
684
+ _run([sys.executable, "-P"] + list(python_args), replace=True)
466
685
 
467
686
 
468
687
  @click.command(context_settings={"ignore_unknown_options": True})
@@ -496,15 +715,28 @@ def run(ctx, args):
496
715
 
497
716
  is_posix = sys.platform in ("linux", "darwin")
498
717
  shell = len(args) == 1
718
+ cmd_args = copy.copy(args)
499
719
  if shell:
500
- args = args[0]
501
-
502
- if shell and not is_posix:
503
- # On Windows, we're going to try to use bash
504
- args = ["bash", "-c", args]
720
+ cmd_args = args[0]
721
+ if not is_posix:
722
+ # On Windows, we're going to try to use bash
723
+ cmd_args = ["bash", "-c", cmd_args]
505
724
 
506
725
  _set_pythonpath(quiet=True)
507
- _run(args, echo=False, shell=shell)
726
+ p = _run(cmd_args, echo=False, shell=shell, sys_exit=False)
727
+
728
+ # Is the user trying to run a Python script, without calling the Python interpreter?
729
+ executable = args[0]
730
+ if (
731
+ (p.returncode != 0)
732
+ and args[0].endswith(".py")
733
+ and os.path.exists(executable)
734
+ and (not os.access(executable, os.X_OK))
735
+ ):
736
+ click.secho(
737
+ f"Did you mean to call `spin run python {' '.join(args)}`?", fg="bright_red"
738
+ )
739
+ sys.exit(p.returncode)
508
740
 
509
741
 
510
742
  @click.command()
@@ -562,15 +794,15 @@ def docs(ctx, sphinx_target, clean, first_build, jobs, sphinx_gallery_plot):
562
794
 
563
795
  if clean:
564
796
  doc_dirs = [
565
- "./doc/build/",
566
- "./doc/source/api/",
567
- "./doc/source/auto_examples/",
568
- "./doc/source/jupyterlite_contents/",
797
+ f"./{doc_dir}/build/",
798
+ f"./{doc_dir}/source/api/",
799
+ f"./{doc_dir}/source/auto_examples/",
800
+ f"./{doc_dir}/source/jupyterlite_contents/",
569
801
  ]
570
- for doc_dir in doc_dirs:
571
- if os.path.isdir(doc_dir):
572
- print(f"Removing {doc_dir!r}")
573
- shutil.rmtree(doc_dir)
802
+ for target_dir in doc_dirs:
803
+ if os.path.isdir(target_dir):
804
+ print(f"Removing {target_dir!r}")
805
+ shutil.rmtree(target_dir)
574
806
 
575
807
  build_cmd = _get_configured_command("build")
576
808
 
@@ -596,11 +828,16 @@ def docs(ctx, sphinx_target, clean, first_build, jobs, sphinx_gallery_plot):
596
828
  f"$ export SPHINXOPTS={os.environ['SPHINXOPTS']}", bold=True, fg="bright_blue"
597
829
  )
598
830
 
599
- os.environ["PYTHONPATH"] = f'{site_path}{os.sep}:{os.environ.get("PYTHONPATH", "")}'
600
- click.secho(
601
- f"$ export PYTHONPATH={os.environ['PYTHONPATH']}", bold=True, fg="bright_blue"
602
- )
603
- _run(["make", "-C", "doc", sphinx_target], replace=True)
831
+ if site_path:
832
+ os.environ["PYTHONPATH"] = (
833
+ f'{site_path}{os.sep}:{os.environ.get("PYTHONPATH", "")}'
834
+ )
835
+ click.secho(
836
+ f"$ export PYTHONPATH={os.environ['PYTHONPATH']}",
837
+ bold=True,
838
+ fg="bright_blue",
839
+ )
840
+ _run(["make", "-C", doc_dir, sphinx_target], replace=True)
604
841
 
605
842
 
606
843
  @click.command()
@@ -0,0 +1,20 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from spin import util
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def pre_post_test():
10
+ # Pre-test code
11
+ cwd = os.getcwd()
12
+ os.chdir("example_pkg")
13
+
14
+ try:
15
+ yield
16
+ finally:
17
+ # Post test code
18
+ os.chdir(cwd)
19
+ util.run(["git", "clean", "-xdf"], cwd="example_pkg")
20
+ os.chdir(cwd)
@@ -0,0 +1,145 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from testutil import skip_on_windows, skip_unless_linux, spin, stdout
9
+
10
+ from spin.cmds.util import run
11
+
12
+
13
+ def test_basic_build():
14
+ """Does the package build?"""
15
+ spin("build")
16
+
17
+ assert Path("build").exists(), "`build` folder not created after `spin build`"
18
+ assert Path(
19
+ "build-install"
20
+ ).exists(), "`build-install` folder not created after `spin build`"
21
+
22
+
23
+ def test_debug_builds():
24
+ """Does spin generate gcov debug output files?"""
25
+ spin("build", "--gcov")
26
+
27
+ debug_files = Path(".").rglob("*.gcno")
28
+ assert len(list(debug_files)) != 0, "debug files not generated for gcov build"
29
+
30
+
31
+ def test_coverage_builds():
32
+ """Does gcov test generate coverage files?"""
33
+ spin("test", "--gcov")
34
+
35
+ coverage_files = Path(".").rglob("*.gcda")
36
+ assert len(list(coverage_files)) != 0, "coverage files not generated for gcov build"
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ "report_type,output_file",
41
+ [
42
+ ("html", Path("coveragereport/index.html")),
43
+ ("xml", Path("coverage.xml")),
44
+ ("text", Path("coverage.txt")),
45
+ ("sonarqube", Path("sonarqube.xml")),
46
+ ],
47
+ )
48
+ def test_coverage_reports(report_type, output_file):
49
+ """Does gcov test generate coverage reports?"""
50
+ spin("test", "--gcov", f"--gcov-format={report_type}")
51
+
52
+ coverage_report = Path("./build/meson-logs", output_file)
53
+ assert (
54
+ coverage_report.exists()
55
+ ), f"coverage report not generated for gcov build ({report_type})"
56
+
57
+
58
+ def test_expand_pythonpath():
59
+ """Does an $ENV_VAR get expanded in `spin run`?"""
60
+ output = spin("run", "echo $PYTHONPATH")
61
+ assert any(
62
+ p in stdout(output) for p in ("site-packages", "dist-packages")
63
+ ), f"Expected value of $PYTHONPATH, got {stdout(output)} instead"
64
+
65
+
66
+ def test_run_stdout():
67
+ """Ensure `spin run` only includes command output on stdout."""
68
+ p = spin(
69
+ "run",
70
+ sys.executable,
71
+ "-c",
72
+ "import sys; del sys.path[0]; import example_pkg; print(example_pkg.__version__)",
73
+ )
74
+ assert (
75
+ stdout(p) == "0.0.0dev0"
76
+ ), f"`spin run` stdout did not yield version, but {stdout(p)}"
77
+
78
+
79
+ # Detecting whether a file is executable is not that easy on Windows,
80
+ # as it seems to take into consideration whether that file is associated as an executable.
81
+ @skip_on_windows
82
+ def test_recommend_run_python():
83
+ """If `spin run file.py` is called, is `spin run python file.py` recommended?"""
84
+ with tempfile.NamedTemporaryFile(suffix=".py") as f:
85
+ p = spin("run", f.name, sys_exit=False)
86
+ assert "Did you mean to call" in stdout(
87
+ p
88
+ ), "Failed to recommend `python run python file.py`"
89
+
90
+
91
+ def test_test():
92
+ """Does the test command run?"""
93
+ spin("test")
94
+
95
+
96
+ def test_test_with_pythonpath():
97
+ """Does `spin test` work when PYTHONPATH is set?"""
98
+ spin("test", env={**os.environ, "PYTHONPATH": "/tmp"})
99
+
100
+
101
+ def test_sdist():
102
+ spin("sdist")
103
+
104
+
105
+ def test_example():
106
+ spin("example")
107
+
108
+
109
+ def test_docs():
110
+ run(["pip", "install", "--quiet", "sphinx"])
111
+ spin("docs")
112
+
113
+
114
+ def test_spin_install():
115
+ cwd = os.getcwd()
116
+ spin("install")
117
+ with tempfile.TemporaryDirectory() as d:
118
+ try:
119
+ os.chdir(d)
120
+ p = run(
121
+ [
122
+ sys.executable,
123
+ "-c",
124
+ "import example_pkg; print(example_pkg.__version__)",
125
+ ],
126
+ stdout=subprocess.PIPE,
127
+ )
128
+ assert stdout(p) == "0.0.0dev0"
129
+ finally:
130
+ os.chdir(cwd)
131
+ run(["pip", "uninstall", "-y", "--quiet", "example_pkg"])
132
+
133
+
134
+ @skip_unless_linux
135
+ def test_gdb():
136
+ p = spin(
137
+ "gdb",
138
+ "-c",
139
+ 'import example_pkg; example_pkg.echo("hi")',
140
+ "--",
141
+ "--eval",
142
+ "run",
143
+ "--batch",
144
+ )
145
+ assert "hi" in stdout(p)
@@ -0,0 +1,17 @@
1
+ from testutil import spin, stdout
2
+
3
+ import spin as libspin
4
+
5
+
6
+ def test_get_version():
7
+ p = spin("--version")
8
+ assert stdout(p) == f"spin {libspin.__version__}"
9
+
10
+
11
+ def test_arg_override():
12
+ p = spin("example")
13
+ assert "--test is: default override" in stdout(p)
14
+ assert "Default kwd is: 3" in stdout(p)
15
+
16
+ p = spin("example", "-t", 6)
17
+ assert "--test is: 6" in stdout(p)
@@ -0,0 +1,21 @@
1
+ import pytest
2
+ from testutil import spin, stdout
3
+
4
+ from spin.cmds.util import run
5
+
6
+
7
+ @pytest.fixture
8
+ def editable_install():
9
+ run(["pip", "install", "--quiet", "--no-build-isolation", "-e", "."])
10
+ yield
11
+ run(["pip", "uninstall", "--quiet", "-y", "example_pkg"])
12
+
13
+
14
+ def test_detect_editable(editable_install):
15
+ assert "Editable install of same source detected" in stdout(
16
+ spin("build")
17
+ ), "Failed to detect and warn about editable install"
18
+
19
+
20
+ def test_editable_tests(editable_install):
21
+ spin("test")
@@ -0,0 +1,32 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import pytest
5
+
6
+ from spin.cmds.util import run
7
+
8
+ skip_on_windows = pytest.mark.skipif(
9
+ sys.platform.startswith("win"), reason="Skipped; platform is Windows"
10
+ )
11
+
12
+ skip_unless_linux = pytest.mark.skipif(
13
+ not sys.platform.startswith("linux"), reason="Skipped; platform not Linux"
14
+ )
15
+
16
+
17
+ def spin(*args, **user_kwargs):
18
+ args = (str(el) for el in args)
19
+ default_kwargs = {
20
+ "stdout": subprocess.PIPE,
21
+ "stderr": subprocess.PIPE,
22
+ "sys_exit": True,
23
+ }
24
+ return run(["spin"] + list(args), **{**default_kwargs, **user_kwargs})
25
+
26
+
27
+ def stdout(p):
28
+ return p.stdout.decode("utf-8").strip()
29
+
30
+
31
+ def stderr(p):
32
+ return p.stderr.decode("utf-8").strip()
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spin
3
- Version: 0.8
3
+ Version: 0.9
4
4
  Summary: Developer tool for scientific Python libraries
5
5
  Maintainer-email: Scientific Python <spin@discuss.scientific-python.org>
6
6
  License: BSD 3-Clause License
7
7
 
8
- Copyright (c) 2021--2022, Scientific Python project
8
+ Copyright (c) 2021--2024, Scientific Python project
9
9
  All rights reserved.
10
10
 
11
11
  Redistribution and use in source and binary forms, with or without
@@ -53,24 +53,28 @@ License-File: LICENSE
53
53
  Requires-Dist: click
54
54
  Requires-Dist: tomli; python_version < "3.11"
55
55
  Requires-Dist: colorama; platform_system == "Windows"
56
+ Requires-Dist: importlib_metadata>=7
56
57
  Provides-Extra: lint
57
- Requires-Dist: pre-commit==3.5.0; extra == "lint"
58
+ Requires-Dist: pre-commit==3.7.0; extra == "lint"
58
59
  Provides-Extra: dev
59
- Requires-Dist: changelist==0.4; extra == "dev"
60
+ Requires-Dist: changelist==0.5; extra == "dev"
60
61
 
61
62
  # 💫 Scientific Python INcantations (`spin`)
62
63
 
63
64
  ## A developer tool for scientific Python libraries
64
65
 
65
66
  Developers need to memorize a whole bunch of magic command-line incantations.
66
- And these incantations change from time to time!
67
- Typically, their lives are made simpler by a Makefile, but Makefiles can be convoluted, are not written in Python, and are hard to extend.
68
- The rationale behind `spin` is therefore to provide a simple interface for common development tasks.
67
+ These incantations may also change over time.
68
+ Often, Makefiles are used to provide aliases, but Makefiles can be convoluted, are not written in Python, and are hard to extend.
69
+ The goal of `spin` is therefore to provide a simple, user-friendly, extendable interface for common development tasks.
69
70
  It comes with a few common build commands out the box, but can easily be customized per project.
70
71
 
71
72
  As a curiosity: the impetus behind developing the tool was the mass migration of scientific Python libraries (SciPy, scikit-image, and NumPy, etc.) to Meson, after distutils was deprecated.
72
73
  When many of the build and installation commands changed, it made sense to abstract away the nuisance of having to re-learn them.
73
74
 
75
+ _Note:_ We now have experimental builds for editable installs.
76
+ Most of the Meson commands listed below should work "out of the box" for those.
77
+
74
78
  ## Installation
75
79
 
76
80
  ```
@@ -152,7 +156,6 @@ Available as `spin.cmds.meson.*`.
152
156
  docs 📖 Build Sphinx documentation
153
157
  gdb 👾 Execute a Python snippet with GDB
154
158
  lldb 👾 Execute a Python snippet with LLDB
155
- install 💽 Build and install package using pip.
156
159
  ```
157
160
 
158
161
  ### [Build](https://pypa-build.readthedocs.io/en/stable/) (PEP 517 builder)
@@ -207,6 +210,29 @@ def example():
207
210
  print(config["tool.spin"])
208
211
  ```
209
212
 
213
+ ### Argument overrides
214
+
215
+ Default arguments can be overridden for any command.
216
+ The custom command above, e.g., has the following signature:
217
+
218
+ ```python
219
+ @click.command()
220
+ @click.option("-f", "--flag")
221
+ @click.option("-t", "--test", default="not set")
222
+ def example(flag, test, default_kwd=None):
223
+ """🧪 Example custom command.
224
+ ...
225
+ """
226
+ ```
227
+
228
+ Use the `[tool.spin.kwargs]` section to override default values for
229
+ click options or function keywords:
230
+
231
+ ```toml
232
+ [tool.spin.kwargs]
233
+ ".spin/cmds.py:example" = {"test" = "default override", "default_kwd" = 3}
234
+ ```
235
+
210
236
  ### Advanced: adding arguments to built-in commands
211
237
 
212
238
  Instead of rewriting a command from scratch, a project may want to add a flag to a built-in `spin` command, or perhaps do some pre- or post-processing.
@@ -284,7 +310,7 @@ fc-cache -f -v
284
310
  `spin` tests are invoked using:
285
311
 
286
312
  ```
287
- nox -s tests
313
+ nox -s test
288
314
  ```
289
315
 
290
316
  ## History
@@ -17,4 +17,9 @@ spin/cmds/build.py
17
17
  spin/cmds/meson.py
18
18
  spin/cmds/pip.py
19
19
  spin/cmds/util.py
20
- spin/tests/test_meson.py
20
+ spin/tests/conftest.py
21
+ spin/tests/test_build_cmds.py
22
+ spin/tests/test_cli.py
23
+ spin/tests/test_editable.py
24
+ spin/tests/test_meson.py
25
+ spin/tests/testutil.py
@@ -1,4 +1,5 @@
1
1
  click
2
+ importlib_metadata>=7
2
3
 
3
4
  [:platform_system == "Windows"]
4
5
  colorama
@@ -7,7 +8,7 @@ colorama
7
8
  tomli
8
9
 
9
10
  [dev]
10
- changelist==0.4
11
+ changelist==0.5
11
12
 
12
13
  [lint]
13
- pre-commit==3.5.0
14
+ pre-commit==3.7.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes