pycontrol-install 0.1.0a1__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.
@@ -0,0 +1,58 @@
1
+ ### macOS
2
+ # Finder metadata
3
+ .DS_Store
4
+
5
+ # Thumbnails
6
+ ._*
7
+
8
+ # Custom folder icons
9
+ Icon
10
+
11
+ # Volume root files
12
+ .DocumentRevisions-V100
13
+ .fseventsd
14
+ .Spotlight-V100
15
+ .TemporaryItems
16
+ .Trashes
17
+ .VolumeIcon.icns
18
+ .com.apple.timemachine.donotpresent
19
+
20
+ ### Python
21
+ # Byte-compiled files
22
+ __pycache__/
23
+ *.py[cod]
24
+ *$py.class
25
+
26
+ # C extensions
27
+ *.so
28
+
29
+ # Distribution / packaging
30
+ build/
31
+ dist/
32
+ *.egg-info/
33
+ *.egg
34
+
35
+ # dotenv environment variable files
36
+ .env
37
+
38
+ # Virtual environments
39
+ .venv
40
+ env/
41
+ venv/
42
+
43
+ # Unit test / coverage reports
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+ .coverage
48
+ .coverage.*
49
+ .pytest_cache/
50
+
51
+ # Type checkers
52
+ .mypy_cache/
53
+
54
+ # Jupyter Notebook
55
+ .ipynb_checkpoints
56
+
57
+ # pyenv
58
+ # .python-version
@@ -0,0 +1,8 @@
1
+ Copyright © 2026 Howard Hughes Medical Institute
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+ Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
7
+ Neither the name of HHMI nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
8
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycontrol-install
3
+ Version: 0.1.0a1
4
+ Summary: uvx installer for the global pyControl GUI and its workspaces.
5
+ Project-URL: Homepage, https://github.com/karpova-lab/pycontrol-install
6
+ Author: pyControl contributors
7
+ License: GPL-3.0-or-later
8
+ License-File: LICENSE.txt
9
+ Keywords: behaviour,neuroscience,pycontrol,uv
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+
19
+ # pycontrol-install
20
+
21
+ `pycontrol-install` is a small `uvx` installer for pyControl. It installs
22
+ `pycontrol-gui` (and the `pycontrol` CLI) **once per machine** with
23
+ `uv tool install`, scaffolds plain-data workspace folders, and remembers the
24
+ workspace so the global `pycontrol-gui` command opens it on the next launch.
25
+
26
+ Install `uv`, then install pyControl and create a workspace:
27
+
28
+ ```bash
29
+ uvx pycontrol-install my-workspace --launch
30
+ ```
31
+
32
+ This:
33
+
34
+ 1. runs `uv tool install pycontrol-gui` into a self-contained, uv-managed
35
+ environment, putting `pycontrol-gui` and `pycontrol` on your PATH,
36
+ 2. scaffolds the `my-workspace` data folder (no `pyproject.toml`, `.venv`, or
37
+ lockfile — workspaces are pure data),
38
+ 3. remembers it as the GUI's current workspace,
39
+ 4. offers to create an OS shortcut (macOS `~/Applications`, Windows Start
40
+ menu, Linux application menu), and
41
+ 5. launches the GUI (`--launch`).
42
+
43
+ After setup, launch the GUI from anywhere with `pycontrol-gui`.
44
+
45
+ To initialize the current directory instead:
46
+
47
+ ```bash
48
+ uvx pycontrol-install . --launch
49
+ ```
50
+
51
+ This package intentionally uses `uv`. Users who prefer pip can still install
52
+ and run pyControl manually from a Python 3.11+ virtual environment; see the
53
+ `pycontrol-core` getting-started guide for the pip/venv workflow.
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ pycontrol-install [path] [--launch] [--force] [--shortcut | --no-shortcut] [--update-gui]
59
+ ```
60
+
61
+ `path` defaults to the current directory. Relative paths are resolved from the
62
+ directory where the command is run. Re-running `pycontrol-install` is safe: the
63
+ global install is refreshed (`--reinstall`) and workspace scaffolding is a safe
64
+ merge.
65
+
66
+ Examples:
67
+
68
+ ```bash
69
+ pycontrol-install
70
+ pycontrol-install my-workspace --launch
71
+ pycontrol-install ~/pycontrol-workspaces/ws1 --with-slack
72
+ pycontrol-install my-workspace --no-shortcut
73
+ ```
74
+
75
+ ## Updating the GUI
76
+
77
+ ```bash
78
+ uvx pycontrol-install --update-gui
79
+ ```
80
+
81
+ Reinstalls the newest `pycontrol-gui` against a freshly refreshed package
82
+ index, preserving integration extras recorded in the uv tool receipt.
83
+ (`uv tool upgrade pycontrol-gui` also works; `--update-gui` additionally
84
+ guarantees the index is revalidated and the pyControl resolution strategy is
85
+ applied.)
86
+
87
+ ## Integration helpers
88
+
89
+ Optional integration dependencies are installed into the global tool
90
+ environment:
91
+
92
+ ```bash
93
+ pycontrol-install my-workspace --with-slack
94
+ pycontrol-install my-workspace --with-notion
95
+ pycontrol-install my-workspace --with-s3
96
+ pycontrol-install my-workspace --with-integrations
97
+ ```
98
+
99
+ To add an integration to an existing install without touching any workspace,
100
+ combine it with `--update-gui`:
101
+
102
+ ```bash
103
+ uvx pycontrol-install --update-gui --with-slack
104
+ ```
105
+
106
+ ## Release smoke tests
107
+
108
+ Package indexes can be overridden:
109
+
110
+ ```bash
111
+ pycontrol-install test-workspace \
112
+ --index-url https://test.pypi.org/simple/ \
113
+ --extra-index-url https://pypi.org/simple/
114
+ ```
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ uv sync --group dev
120
+ uv run pytest
121
+ uv run ruff check src tests
122
+ ```
@@ -0,0 +1,104 @@
1
+ # pycontrol-install
2
+
3
+ `pycontrol-install` is a small `uvx` installer for pyControl. It installs
4
+ `pycontrol-gui` (and the `pycontrol` CLI) **once per machine** with
5
+ `uv tool install`, scaffolds plain-data workspace folders, and remembers the
6
+ workspace so the global `pycontrol-gui` command opens it on the next launch.
7
+
8
+ Install `uv`, then install pyControl and create a workspace:
9
+
10
+ ```bash
11
+ uvx pycontrol-install my-workspace --launch
12
+ ```
13
+
14
+ This:
15
+
16
+ 1. runs `uv tool install pycontrol-gui` into a self-contained, uv-managed
17
+ environment, putting `pycontrol-gui` and `pycontrol` on your PATH,
18
+ 2. scaffolds the `my-workspace` data folder (no `pyproject.toml`, `.venv`, or
19
+ lockfile — workspaces are pure data),
20
+ 3. remembers it as the GUI's current workspace,
21
+ 4. offers to create an OS shortcut (macOS `~/Applications`, Windows Start
22
+ menu, Linux application menu), and
23
+ 5. launches the GUI (`--launch`).
24
+
25
+ After setup, launch the GUI from anywhere with `pycontrol-gui`.
26
+
27
+ To initialize the current directory instead:
28
+
29
+ ```bash
30
+ uvx pycontrol-install . --launch
31
+ ```
32
+
33
+ This package intentionally uses `uv`. Users who prefer pip can still install
34
+ and run pyControl manually from a Python 3.11+ virtual environment; see the
35
+ `pycontrol-core` getting-started guide for the pip/venv workflow.
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ pycontrol-install [path] [--launch] [--force] [--shortcut | --no-shortcut] [--update-gui]
41
+ ```
42
+
43
+ `path` defaults to the current directory. Relative paths are resolved from the
44
+ directory where the command is run. Re-running `pycontrol-install` is safe: the
45
+ global install is refreshed (`--reinstall`) and workspace scaffolding is a safe
46
+ merge.
47
+
48
+ Examples:
49
+
50
+ ```bash
51
+ pycontrol-install
52
+ pycontrol-install my-workspace --launch
53
+ pycontrol-install ~/pycontrol-workspaces/ws1 --with-slack
54
+ pycontrol-install my-workspace --no-shortcut
55
+ ```
56
+
57
+ ## Updating the GUI
58
+
59
+ ```bash
60
+ uvx pycontrol-install --update-gui
61
+ ```
62
+
63
+ Reinstalls the newest `pycontrol-gui` against a freshly refreshed package
64
+ index, preserving integration extras recorded in the uv tool receipt.
65
+ (`uv tool upgrade pycontrol-gui` also works; `--update-gui` additionally
66
+ guarantees the index is revalidated and the pyControl resolution strategy is
67
+ applied.)
68
+
69
+ ## Integration helpers
70
+
71
+ Optional integration dependencies are installed into the global tool
72
+ environment:
73
+
74
+ ```bash
75
+ pycontrol-install my-workspace --with-slack
76
+ pycontrol-install my-workspace --with-notion
77
+ pycontrol-install my-workspace --with-s3
78
+ pycontrol-install my-workspace --with-integrations
79
+ ```
80
+
81
+ To add an integration to an existing install without touching any workspace,
82
+ combine it with `--update-gui`:
83
+
84
+ ```bash
85
+ uvx pycontrol-install --update-gui --with-slack
86
+ ```
87
+
88
+ ## Release smoke tests
89
+
90
+ Package indexes can be overridden:
91
+
92
+ ```bash
93
+ pycontrol-install test-workspace \
94
+ --index-url https://test.pypi.org/simple/ \
95
+ --extra-index-url https://pypi.org/simple/
96
+ ```
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ uv sync --group dev
102
+ uv run pytest
103
+ uv run ruff check src tests
104
+ ```
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pycontrol-install"
7
+ version = "0.1.0a1"
8
+ description = "uvx installer for the global pyControl GUI and its workspaces."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "GPL-3.0-or-later" }
12
+ authors = [{ name = "pyControl contributors" }]
13
+ keywords = ["neuroscience", "behaviour", "pycontrol", "uv"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Topic :: Scientific/Engineering",
21
+ ]
22
+
23
+ [project.scripts]
24
+ pycontrol-install = "pycontrol_install.cli:main"
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/karpova-lab/pycontrol-install"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8",
32
+ "ruff>=0.5",
33
+ ]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ exclude = [
37
+ "/.git",
38
+ "/.pytest_cache",
39
+ "/.ruff_cache",
40
+ "/.venv",
41
+ "/build",
42
+ "/dist",
43
+ "*.egg-info",
44
+ ]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/pycontrol_install"]
48
+
49
+ [tool.pytest.ini_options]
50
+ minversion = "8.0"
51
+ addopts = "-ra --strict-markers --strict-config"
52
+ testpaths = ["tests"]
53
+ pythonpath = ["src"]
54
+
55
+ [tool.ruff]
56
+ line-length = 120
57
+ target-version = "py311"
58
+ src = ["src", "tests"]
@@ -0,0 +1,5 @@
1
+ """pyControl global installer."""
2
+
3
+ __version__ = "0.1.0a1"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,334 @@
1
+ """Command-line installer for the global pyControl app and its workspaces.
2
+
3
+ ``pycontrol-install`` installs ``pycontrol-gui`` once per machine with
4
+ ``uv tool install``, scaffolds plain-data workspace folders, and remembers the
5
+ workspace so the globally installed ``pycontrol-gui`` command opens it on the
6
+ next launch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import shlex
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tomllib
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Protocol
22
+
23
+ from pycontrol_install.shortcuts import create_shortcut
24
+
25
+ GUI_PACKAGE = "pycontrol-gui"
26
+ CORE_PACKAGE = "pycontrol-core"
27
+ _INTEGRATION_EXTRAS = ("notion", "s3", "slack")
28
+ _PRERELEASE_CHOICES = ("allow", "disallow", "if-necessary", "explicit", "if-necessary-or-explicit")
29
+
30
+
31
+ class BootstrapError(RuntimeError):
32
+ """Expected bootstrap failure suitable for a one-line CLI error."""
33
+
34
+
35
+ class CommandRunner(Protocol):
36
+ """Small abstraction for testing command construction without invoking uv."""
37
+
38
+ def has_command(self, name: str) -> bool: ...
39
+
40
+ def run(self, cwd: Path, command: Sequence[str]) -> None: ...
41
+
42
+ def run_capture(self, cwd: Path, command: Sequence[str]) -> str: ...
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class BootstrapConfig:
47
+ """Parsed bootstrap options."""
48
+
49
+ target: Path
50
+ launch: bool
51
+ force: bool
52
+ python: str
53
+ prerelease: str
54
+ index_url: str | None
55
+ extra_index_url: str | None
56
+ integration_extras: tuple[str, ...]
57
+ update_gui: bool
58
+ shortcut: bool | None
59
+
60
+
61
+ class SubprocessRunner:
62
+ """Run commands on the local machine."""
63
+
64
+ def has_command(self, name: str) -> bool:
65
+ return shutil.which(name) is not None
66
+
67
+ def run(self, cwd: Path, command: Sequence[str]) -> None:
68
+ print(f"Running in {cwd}: {shlex.join(command)}")
69
+ try:
70
+ subprocess.run(command, cwd=cwd, check=True)
71
+ except FileNotFoundError as exc:
72
+ raise BootstrapError(f"command not found: {command[0]}") from exc
73
+ except subprocess.CalledProcessError as exc:
74
+ raise BootstrapError(f"command failed with exit code {exc.returncode}: {shlex.join(command)}") from exc
75
+
76
+ def run_capture(self, cwd: Path, command: Sequence[str]) -> str:
77
+ try:
78
+ completed = subprocess.run(command, cwd=cwd, check=True, capture_output=True, text=True)
79
+ except FileNotFoundError as exc:
80
+ raise BootstrapError(f"command not found: {command[0]}") from exc
81
+ except subprocess.CalledProcessError as exc:
82
+ raise BootstrapError(f"command failed with exit code {exc.returncode}: {shlex.join(command)}") from exc
83
+ return completed.stdout.strip()
84
+
85
+
86
+ def build_parser() -> argparse.ArgumentParser:
87
+ parser = argparse.ArgumentParser(
88
+ prog="pycontrol-install",
89
+ description="Install the pyControl GUI globally with uv and create a workspace data folder.",
90
+ )
91
+ parser.add_argument(
92
+ "path",
93
+ nargs="?",
94
+ default=".",
95
+ help="Workspace path to create or initialize (default: current directory).",
96
+ )
97
+ parser.add_argument(
98
+ "--launch",
99
+ action="store_true",
100
+ help="Launch the pyControl GUI after the workspace is ready.",
101
+ )
102
+ parser.add_argument(
103
+ "--force",
104
+ action="store_true",
105
+ help="Pass --force to workspace scaffolding so differing bundled files are overwritten.",
106
+ )
107
+ parser.add_argument(
108
+ "--update-gui",
109
+ action="store_true",
110
+ help="Reinstall the newest pyControl GUI (preserving installed integration extras) and exit.",
111
+ )
112
+ parser.add_argument(
113
+ "--shortcut",
114
+ action=argparse.BooleanOptionalAction,
115
+ default=None,
116
+ help="Create (or skip) an OS shortcut for the pyControl GUI without prompting.",
117
+ )
118
+ parser.add_argument(
119
+ "--python",
120
+ default="3.12",
121
+ help="Python version request for the global tool environment (default: 3.12).",
122
+ )
123
+ parser.add_argument(
124
+ "--prerelease",
125
+ choices=_PRERELEASE_CHOICES,
126
+ default="if-necessary-or-explicit",
127
+ help=(
128
+ "uv prerelease resolution strategy (default: if-necessary-or-explicit, which selects pyControl "
129
+ "alphas because no stable release exists yet while keeping third-party dependencies stable)."
130
+ ),
131
+ )
132
+ parser.add_argument(
133
+ "--index-url",
134
+ default=None,
135
+ help="Package index URL for uv tool install, or PYCONTROL_INSTALL_INDEX_URL.",
136
+ )
137
+ parser.add_argument(
138
+ "--extra-index-url",
139
+ default=None,
140
+ help="Extra package index URL for uv tool install, or PYCONTROL_INSTALL_EXTRA_INDEX_URL.",
141
+ )
142
+ parser.add_argument(
143
+ "--with-slack",
144
+ action="store_true",
145
+ help="Install Slack notification dependencies into the global tool environment.",
146
+ )
147
+ parser.add_argument(
148
+ "--with-notion",
149
+ action="store_true",
150
+ help="Install Notion helper dependencies into the global tool environment.",
151
+ )
152
+ parser.add_argument(
153
+ "--with-s3",
154
+ action="store_true",
155
+ help="Install S3 export helper dependencies into the global tool environment.",
156
+ )
157
+ parser.add_argument(
158
+ "--with-integrations",
159
+ action="store_true",
160
+ help="Install all optional integration helper dependencies into the global tool environment.",
161
+ )
162
+ return parser
163
+
164
+
165
+ def config_from_args(args: argparse.Namespace) -> BootstrapConfig:
166
+ selected = set(_INTEGRATION_EXTRAS if args.with_integrations else ())
167
+ if args.with_notion:
168
+ selected.add("notion")
169
+ if args.with_s3:
170
+ selected.add("s3")
171
+ if args.with_slack:
172
+ selected.add("slack")
173
+
174
+ return BootstrapConfig(
175
+ target=Path(args.path),
176
+ launch=bool(args.launch),
177
+ force=bool(args.force),
178
+ python=str(args.python),
179
+ prerelease=str(args.prerelease),
180
+ index_url=args.index_url or os.environ.get("PYCONTROL_INSTALL_INDEX_URL") or None,
181
+ extra_index_url=args.extra_index_url or os.environ.get("PYCONTROL_INSTALL_EXTRA_INDEX_URL") or None,
182
+ integration_extras=tuple(extra for extra in _INTEGRATION_EXTRAS if extra in selected),
183
+ update_gui=bool(args.update_gui),
184
+ shortcut=args.shortcut,
185
+ )
186
+
187
+
188
+ def bootstrap(config: BootstrapConfig, runner: CommandRunner | None = None) -> Path | None:
189
+ """Install the GUI globally, then scaffold and remember the workspace.
190
+
191
+ Returns the workspace path, or ``None`` for ``--update-gui`` runs.
192
+ """
193
+ runner = runner or SubprocessRunner()
194
+ if not runner.has_command("uv"):
195
+ raise BootstrapError(
196
+ "uv is required because pycontrol-install uses uv; install it from "
197
+ "https://docs.astral.sh/uv/getting-started/installation/"
198
+ )
199
+
200
+ cwd = Path.cwd()
201
+ receipt = _receipt_path(runner, cwd)
202
+
203
+ if config.update_gui:
204
+ if receipt is None or not receipt.is_file():
205
+ raise BootstrapError(
206
+ f"{GUI_PACKAGE} is not installed as a uv tool yet; run pycontrol-install without --update-gui first."
207
+ )
208
+ extras = tuple(sorted({*_extras_from_receipt(receipt), *config.integration_extras}))
209
+ runner.run(cwd, _install_command(config, extras=extras, reinstall=True))
210
+ print(f"{GUI_PACKAGE} reinstalled at the newest version.")
211
+ return None
212
+
213
+ reinstall = receipt is not None and receipt.is_file()
214
+ runner.run(cwd, _install_command(config, extras=config.integration_extras, reinstall=reinstall))
215
+
216
+ bin_dir = Path(runner.run_capture(cwd, ["uv", "tool", "dir", "--bin"]))
217
+ target = _prepare_target(config.target)
218
+
219
+ workspace_init = [str(bin_dir / "pycontrol"), "workspace", "init", str(target)]
220
+ if config.force:
221
+ workspace_init.append("--force")
222
+ runner.run(target, workspace_init)
223
+
224
+ runner.run(target, [str(bin_dir / GUI_PACKAGE), "--set-workspace", str(target)])
225
+
226
+ if _shortcut_wanted(config.shortcut):
227
+ _create_shortcut_nonfatal(bin_dir)
228
+
229
+ if config.launch:
230
+ runner.run(target, [str(bin_dir / GUI_PACKAGE), str(target)])
231
+
232
+ return target
233
+
234
+
235
+ def main(argv: Sequence[str] | None = None) -> int:
236
+ parser = build_parser()
237
+ args = parser.parse_args(argv)
238
+ try:
239
+ workspace = bootstrap(config_from_args(args))
240
+ except BootstrapError as exc:
241
+ print(f"error: {exc}", file=sys.stderr)
242
+ return 2
243
+
244
+ if workspace is not None:
245
+ print(f"Workspace ready: {workspace}")
246
+ if not args.launch:
247
+ print(f"Launch anytime with: {GUI_PACKAGE}")
248
+ print(f"If the {GUI_PACKAGE} command is not found in new terminals, run: uv tool update-shell")
249
+ return 0
250
+
251
+
252
+ def _install_command(config: BootstrapConfig, *, extras: tuple[str, ...], reinstall: bool) -> list[str]:
253
+ # --refresh: resolve against a revalidated index so a release published
254
+ # moments ago is picked up instead of a cached older version.
255
+ # --force: claim the pycontrol/pycontrol-gui executables even when a stale
256
+ # shim or an older tool install already provides them.
257
+ command = [
258
+ "uv",
259
+ "tool",
260
+ "install",
261
+ "--refresh",
262
+ "--force",
263
+ "--python",
264
+ config.python,
265
+ "--prerelease",
266
+ config.prerelease,
267
+ ]
268
+ if config.index_url:
269
+ command.extend(["--index-url", config.index_url])
270
+ if config.extra_index_url:
271
+ command.extend(["--extra-index-url", config.extra_index_url])
272
+ if extras:
273
+ command.extend(["--with", f"{CORE_PACKAGE}[{','.join(sorted(extras))}]"])
274
+ command.extend(["--with-executables-from", CORE_PACKAGE])
275
+ if reinstall:
276
+ command.append("--reinstall")
277
+ command.append(GUI_PACKAGE)
278
+ return command
279
+
280
+
281
+ def _receipt_path(runner: CommandRunner, cwd: Path) -> Path | None:
282
+ try:
283
+ tool_dir = runner.run_capture(cwd, ["uv", "tool", "dir"])
284
+ except BootstrapError:
285
+ return None
286
+ if not tool_dir:
287
+ return None
288
+ return Path(tool_dir) / GUI_PACKAGE / "uv-receipt.toml"
289
+
290
+
291
+ def _extras_from_receipt(receipt: Path) -> tuple[str, ...]:
292
+ """Recover ``pycontrol-core`` extras from the uv tool receipt so an update
293
+ reinstall keeps previously chosen integrations."""
294
+ try:
295
+ data = tomllib.loads(receipt.read_text(encoding="utf-8"))
296
+ except (OSError, tomllib.TOMLDecodeError):
297
+ return ()
298
+ requirements = data.get("tool", {}).get("requirements", [])
299
+ for requirement in requirements:
300
+ if isinstance(requirement, dict) and requirement.get("name") == CORE_PACKAGE:
301
+ extras = requirement.get("extras", [])
302
+ return tuple(sorted(str(extra) for extra in extras if str(extra) in _INTEGRATION_EXTRAS))
303
+ return ()
304
+
305
+
306
+ def _prepare_target(raw_target: Path) -> Path:
307
+ target = raw_target.expanduser().resolve()
308
+ if target.exists() and not target.is_dir():
309
+ raise BootstrapError(f"target exists and is not a directory: {target}")
310
+ target.mkdir(parents=True, exist_ok=True)
311
+ return target
312
+
313
+
314
+ def _shortcut_wanted(shortcut: bool | None) -> bool:
315
+ if shortcut is not None:
316
+ return shortcut
317
+ if not sys.stdin.isatty():
318
+ return False
319
+ answer = input("Create an OS shortcut for the pyControl GUI? [Y/n] ").strip().lower()
320
+ return answer in ("", "y", "yes")
321
+
322
+
323
+ def _create_shortcut_nonfatal(bin_dir: Path) -> None:
324
+ try:
325
+ created = create_shortcut(bin_dir)
326
+ except Exception as exc: # noqa: BLE001 - shortcuts are a nicety, never fatal.
327
+ print(f"warning: could not create a shortcut: {exc}", file=sys.stderr)
328
+ return
329
+ if created is not None:
330
+ print(f"Shortcut created: {created}")
331
+
332
+
333
+ if __name__ == "__main__": # pragma: no cover
334
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """Optional OS shortcuts pointing at the globally installed ``pycontrol-gui``.
2
+
3
+ All variants target the stable uv tool shim (``<uv tool bin dir>/pycontrol-gui``),
4
+ which survives upgrades, so shortcuts never need regeneration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import plistlib
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ APP_NAME = "pyControl GUI"
15
+ _BUNDLE_IDENTIFIER = "org.pycontrol.gui-launcher"
16
+
17
+
18
+ def create_shortcut(bin_dir: Path, home: Path | None = None, platform: str | None = None) -> Path | None:
19
+ """Create a double-clickable shortcut for the current OS.
20
+
21
+ Returns the created shortcut path, or ``None`` when the platform has no
22
+ shortcut support. Raises on failure; callers treat that as non-fatal.
23
+ """
24
+ home = home or Path.home()
25
+ platform = platform or sys.platform
26
+ if platform == "darwin":
27
+ return create_macos_app_bundle(bin_dir, home)
28
+ if platform == "win32":
29
+ return create_windows_start_menu_shortcut(bin_dir)
30
+ if platform.startswith("linux"):
31
+ return create_linux_desktop_entry(bin_dir, home)
32
+ return None
33
+
34
+
35
+ def create_macos_app_bundle(bin_dir: Path, home: Path) -> Path:
36
+ """Minimal ~/Applications app bundle wrapping the pycontrol-gui shim."""
37
+ bundle = home / "Applications" / f"{APP_NAME}.app"
38
+ macos_dir = bundle / "Contents" / "MacOS"
39
+ macos_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ info = {
42
+ "CFBundleName": APP_NAME,
43
+ "CFBundleDisplayName": APP_NAME,
44
+ "CFBundleIdentifier": _BUNDLE_IDENTIFIER,
45
+ "CFBundlePackageType": "APPL",
46
+ "CFBundleExecutable": "pycontrol-gui-launcher",
47
+ }
48
+ with (bundle / "Contents" / "Info.plist").open("wb") as stream:
49
+ plistlib.dump(info, stream)
50
+
51
+ launcher = macos_dir / "pycontrol-gui-launcher"
52
+ launcher.write_text(macos_launcher_script(bin_dir), encoding="utf-8", newline="\n")
53
+ launcher.chmod(launcher.stat().st_mode | 0o111)
54
+ return bundle
55
+
56
+
57
+ def macos_launcher_script(bin_dir: Path) -> str:
58
+ return f'#!/bin/sh\nexec "{bin_dir / "pycontrol-gui"}" "$@"\n'
59
+
60
+
61
+ def create_windows_start_menu_shortcut(bin_dir: Path) -> Path:
62
+ """Start-menu .lnk via the WScript.Shell COM object (no extra dependencies)."""
63
+ completed = subprocess.run(
64
+ ["powershell", "-NoProfile", "-Command", windows_shortcut_command(bin_dir)],
65
+ check=True,
66
+ capture_output=True,
67
+ text=True,
68
+ )
69
+ link_path = completed.stdout.strip().splitlines()[-1] if completed.stdout.strip() else ""
70
+ if not link_path:
71
+ raise RuntimeError("PowerShell did not report the created shortcut path")
72
+ return Path(link_path)
73
+
74
+
75
+ def windows_shortcut_command(bin_dir: Path) -> str:
76
+ target = bin_dir / "pycontrol-gui.exe"
77
+ return (
78
+ "$programs = [Environment]::GetFolderPath('Programs'); "
79
+ f"$link = Join-Path $programs '{APP_NAME}.lnk'; "
80
+ "$shell = New-Object -ComObject WScript.Shell; "
81
+ "$shortcut = $shell.CreateShortcut($link); "
82
+ f"$shortcut.TargetPath = '{target}'; "
83
+ "$shortcut.Save(); "
84
+ "Write-Output $link"
85
+ )
86
+
87
+
88
+ def create_linux_desktop_entry(bin_dir: Path, home: Path) -> Path:
89
+ applications = home / ".local" / "share" / "applications"
90
+ applications.mkdir(parents=True, exist_ok=True)
91
+ entry = applications / "pycontrol-gui.desktop"
92
+ entry.write_text(linux_desktop_entry(bin_dir), encoding="utf-8", newline="\n")
93
+ return entry
94
+
95
+
96
+ def linux_desktop_entry(bin_dir: Path) -> str:
97
+ return (
98
+ "[Desktop Entry]\n"
99
+ "Type=Application\n"
100
+ f"Name={APP_NAME}\n"
101
+ f'Exec="{bin_dir / "pycontrol-gui"}"\n'
102
+ "Terminal=false\n"
103
+ "Categories=Science;\n"
104
+ )
105
+
106
+
107
+ __all__ = [
108
+ "APP_NAME",
109
+ "create_linux_desktop_entry",
110
+ "create_macos_app_bundle",
111
+ "create_shortcut",
112
+ "create_windows_start_menu_shortcut",
113
+ "linux_desktop_entry",
114
+ "macos_launcher_script",
115
+ "windows_shortcut_command",
116
+ ]
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from pycontrol_install.cli import BootstrapError, bootstrap, build_parser, config_from_args, main
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Call:
13
+ cwd: Path
14
+ command: tuple[str, ...]
15
+
16
+
17
+ class FakeRunner:
18
+ def __init__(self, *, has_uv: bool = True, tool_dir: Path | None = None, bin_dir: Path | None = None) -> None:
19
+ self.has_uv = has_uv
20
+ self.tool_dir = tool_dir
21
+ self.bin_dir = bin_dir or Path("/fake/bin")
22
+ self.calls: list[Call] = []
23
+
24
+ def has_command(self, name: str) -> bool:
25
+ return name == "uv" and self.has_uv
26
+
27
+ def run(self, cwd: Path, command: tuple[str, ...] | list[str]) -> None:
28
+ self.calls.append(Call(cwd=cwd, command=tuple(command)))
29
+
30
+ def run_capture(self, cwd: Path, command: tuple[str, ...] | list[str]) -> str:
31
+ self.calls.append(Call(cwd=cwd, command=tuple(command)))
32
+ if tuple(command) == ("uv", "tool", "dir"):
33
+ if self.tool_dir is None:
34
+ return ""
35
+ return str(self.tool_dir)
36
+ if tuple(command) == ("uv", "tool", "dir", "--bin"):
37
+ return str(self.bin_dir)
38
+ raise AssertionError(f"unexpected capture command: {command}")
39
+
40
+
41
+ def _config(argv: list[str]):
42
+ parser = build_parser()
43
+ return config_from_args(parser.parse_args(argv))
44
+
45
+
46
+ def _install_command(*, extras: str | None = None, reinstall: bool = False, index: tuple[str, ...] = ()) -> tuple[str, ...]:
47
+ command = [
48
+ "uv",
49
+ "tool",
50
+ "install",
51
+ "--refresh",
52
+ "--force",
53
+ "--python",
54
+ "3.12",
55
+ "--prerelease",
56
+ "if-necessary-or-explicit",
57
+ *index,
58
+ ]
59
+ if extras:
60
+ command.extend(["--with", f"pycontrol-core[{extras}]"])
61
+ command.extend(["--with-executables-from", "pycontrol-core"])
62
+ if reinstall:
63
+ command.append("--reinstall")
64
+ command.append("pycontrol-gui")
65
+ return tuple(command)
66
+
67
+
68
+ def _write_receipt(tool_dir: Path, *, extras: list[str] | None = None) -> None:
69
+ package_dir = tool_dir / "pycontrol-gui"
70
+ package_dir.mkdir(parents=True, exist_ok=True)
71
+ extras_toml = ""
72
+ if extras:
73
+ listed = ", ".join(f'"{extra}"' for extra in extras)
74
+ extras_toml = f", extras = [{listed}]"
75
+ (package_dir / "uv-receipt.toml").write_text(
76
+ "[tool]\n"
77
+ "requirements = [\n"
78
+ ' { name = "pycontrol-gui" },\n'
79
+ f' {{ name = "pycontrol-core"{extras_toml} }},\n'
80
+ "]\n",
81
+ encoding="utf-8",
82
+ )
83
+
84
+
85
+ def test_defaults_install_globally_and_initialize_current_directory(
86
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
87
+ ) -> None:
88
+ monkeypatch.chdir(tmp_path)
89
+ runner = FakeRunner(tool_dir=tmp_path / "tools", bin_dir=tmp_path / "bin")
90
+
91
+ workspace = bootstrap(_config(["--no-shortcut"]), runner)
92
+
93
+ ws = tmp_path.resolve()
94
+ bin_dir = tmp_path / "bin"
95
+ assert workspace == ws
96
+ assert runner.calls == [
97
+ Call(ws, ("uv", "tool", "dir")),
98
+ Call(ws, _install_command()),
99
+ Call(ws, ("uv", "tool", "dir", "--bin")),
100
+ Call(ws, (str(bin_dir / "pycontrol"), "workspace", "init", str(ws))),
101
+ Call(ws, (str(bin_dir / "pycontrol-gui"), "--set-workspace", str(ws))),
102
+ ]
103
+
104
+
105
+ def test_relative_path_creates_child_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
106
+ monkeypatch.chdir(tmp_path)
107
+ runner = FakeRunner(tool_dir=tmp_path / "tools")
108
+
109
+ workspace = bootstrap(_config(["my-workspace", "--no-shortcut"]), runner)
110
+
111
+ assert workspace == (tmp_path / "my-workspace").resolve()
112
+ assert workspace.is_dir()
113
+
114
+
115
+ def test_existing_install_adds_reinstall(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
116
+ monkeypatch.chdir(tmp_path)
117
+ tool_dir = tmp_path / "tools"
118
+ _write_receipt(tool_dir)
119
+ runner = FakeRunner(tool_dir=tool_dir)
120
+
121
+ bootstrap(_config(["--no-shortcut"]), runner)
122
+
123
+ assert runner.calls[1].command == _install_command(reinstall=True)
124
+
125
+
126
+ def test_force_only_changes_the_workspace_init_step(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
127
+ monkeypatch.chdir(tmp_path)
128
+ runner = FakeRunner(tool_dir=tmp_path / "tools", bin_dir=tmp_path / "bin")
129
+
130
+ bootstrap(_config(["ws", "--force", "--no-shortcut"]), runner)
131
+
132
+ ws = (tmp_path / "ws").resolve()
133
+ workspace_init = runner.calls[3].command
134
+ # The install command is unchanged by the user's --force flag ...
135
+ assert runner.calls[1].command == _install_command()
136
+ # ... which is instead forwarded to workspace scaffolding.
137
+ assert workspace_init == (str(tmp_path / "bin" / "pycontrol"), "workspace", "init", str(ws), "--force")
138
+
139
+
140
+ def test_file_target_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
141
+ monkeypatch.chdir(tmp_path)
142
+ target = tmp_path / "workspace.txt"
143
+ target.write_text("not a directory\n")
144
+
145
+ with pytest.raises(BootstrapError, match="not a directory"):
146
+ bootstrap(_config([str(target), "--no-shortcut"]), FakeRunner(tool_dir=tmp_path / "tools"))
147
+
148
+
149
+ def test_launch_runs_global_gui_with_workspace_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
150
+ monkeypatch.chdir(tmp_path)
151
+ runner = FakeRunner(tool_dir=tmp_path / "tools", bin_dir=tmp_path / "bin")
152
+
153
+ bootstrap(_config(["ws", "--launch", "--no-shortcut"]), runner)
154
+
155
+ ws = (tmp_path / "ws").resolve()
156
+ assert runner.calls[-1] == Call(ws, (str(tmp_path / "bin" / "pycontrol-gui"), str(ws)))
157
+
158
+
159
+ def test_integration_extras_are_installed_globally(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
160
+ monkeypatch.chdir(tmp_path)
161
+ runner = FakeRunner(tool_dir=tmp_path / "tools")
162
+
163
+ bootstrap(_config(["ws", "--with-slack", "--with-s3", "--no-shortcut"]), runner)
164
+
165
+ assert runner.calls[1].command == _install_command(extras="s3,slack")
166
+
167
+
168
+ def test_all_integrations_are_added_in_deterministic_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
169
+ monkeypatch.chdir(tmp_path)
170
+ runner = FakeRunner(tool_dir=tmp_path / "tools")
171
+
172
+ bootstrap(_config(["ws", "--with-integrations", "--no-shortcut"]), runner)
173
+
174
+ assert "pycontrol-core[notion,s3,slack]" in runner.calls[1].command
175
+
176
+
177
+ def test_index_options_are_passed_to_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
178
+ monkeypatch.chdir(tmp_path)
179
+ runner = FakeRunner(tool_dir=tmp_path / "tools")
180
+
181
+ bootstrap(
182
+ _config(
183
+ [
184
+ "ws",
185
+ "--no-shortcut",
186
+ "--index-url",
187
+ "https://test.pypi.org/simple/",
188
+ "--extra-index-url",
189
+ "https://pypi.org/simple/",
190
+ ]
191
+ ),
192
+ runner,
193
+ )
194
+
195
+ assert runner.calls[1].command == _install_command(
196
+ index=(
197
+ "--index-url",
198
+ "https://test.pypi.org/simple/",
199
+ "--extra-index-url",
200
+ "https://pypi.org/simple/",
201
+ )
202
+ )
203
+
204
+
205
+ def test_update_gui_reinstalls_with_receipt_extras_and_skips_scaffolding(
206
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
207
+ ) -> None:
208
+ monkeypatch.chdir(tmp_path)
209
+ tool_dir = tmp_path / "tools"
210
+ _write_receipt(tool_dir, extras=["slack", "notion"])
211
+ runner = FakeRunner(tool_dir=tool_dir)
212
+
213
+ result = bootstrap(_config(["--update-gui"]), runner)
214
+
215
+ assert result is None
216
+ assert runner.calls == [
217
+ Call(tmp_path.resolve(), ("uv", "tool", "dir")),
218
+ Call(tmp_path.resolve(), _install_command(extras="notion,slack", reinstall=True)),
219
+ ]
220
+
221
+
222
+ def test_update_gui_merges_new_extras_with_receipt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
223
+ monkeypatch.chdir(tmp_path)
224
+ tool_dir = tmp_path / "tools"
225
+ _write_receipt(tool_dir, extras=["slack"])
226
+ runner = FakeRunner(tool_dir=tool_dir)
227
+
228
+ bootstrap(_config(["--update-gui", "--with-s3"]), runner)
229
+
230
+ assert runner.calls[1].command == _install_command(extras="s3,slack", reinstall=True)
231
+
232
+
233
+ def test_update_gui_without_existing_install_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
234
+ monkeypatch.chdir(tmp_path)
235
+
236
+ with pytest.raises(BootstrapError, match="not installed"):
237
+ bootstrap(_config(["--update-gui"]), FakeRunner(tool_dir=tmp_path / "tools"))
238
+
239
+
240
+ def test_missing_uv_is_reported() -> None:
241
+ with pytest.raises(BootstrapError, match="uv is required"):
242
+ bootstrap(_config([]), FakeRunner(has_uv=False))
243
+
244
+
245
+ def test_main_reports_bootstrap_errors(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
246
+ def fail(_config):
247
+ raise BootstrapError("bad workspace")
248
+
249
+ monkeypatch.setattr("pycontrol_install.cli.bootstrap", fail)
250
+
251
+ rc = main(["."])
252
+
253
+ assert rc == 2
254
+ assert capsys.readouterr().err == "error: bad workspace\n"
255
+
256
+
257
+ def test_main_prints_launch_hint(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
258
+ monkeypatch.setattr("pycontrol_install.cli.bootstrap", lambda _config: tmp_path)
259
+
260
+ rc = main(["."])
261
+
262
+ assert rc == 0
263
+ out = capsys.readouterr().out
264
+ assert f"Workspace ready: {tmp_path}" in out
265
+ assert "Launch anytime with: pycontrol-gui" in out
266
+ assert "uv tool update-shell" in out
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import plistlib
5
+ from pathlib import Path
6
+
7
+ from pycontrol_install import shortcuts
8
+
9
+
10
+ def test_macos_app_bundle_wraps_the_gui_shim(tmp_path: Path) -> None:
11
+ bin_dir = tmp_path / "bin"
12
+ home = tmp_path / "home"
13
+
14
+ bundle = shortcuts.create_macos_app_bundle(bin_dir, home)
15
+
16
+ assert bundle == home / "Applications" / "pyControl GUI.app"
17
+ with (bundle / "Contents" / "Info.plist").open("rb") as stream:
18
+ info = plistlib.load(stream)
19
+ assert info["CFBundleExecutable"] == "pycontrol-gui-launcher"
20
+ assert info["CFBundlePackageType"] == "APPL"
21
+ launcher = bundle / "Contents" / "MacOS" / "pycontrol-gui-launcher"
22
+ text = launcher.read_text(encoding="utf-8")
23
+ assert text.startswith("#!/bin/sh\n")
24
+ assert str(bin_dir / "pycontrol-gui") in text
25
+ assert os.access(launcher, os.X_OK)
26
+
27
+
28
+ def test_macos_bundle_overwrites_existing(tmp_path: Path) -> None:
29
+ bin_dir = tmp_path / "bin"
30
+ home = tmp_path / "home"
31
+ shortcuts.create_macos_app_bundle(tmp_path / "old-bin", home)
32
+
33
+ shortcuts.create_macos_app_bundle(bin_dir, home)
34
+
35
+ launcher = home / "Applications" / "pyControl GUI.app" / "Contents" / "MacOS" / "pycontrol-gui-launcher"
36
+ assert str(bin_dir / "pycontrol-gui") in launcher.read_text(encoding="utf-8")
37
+
38
+
39
+ def test_windows_shortcut_command_targets_exe(tmp_path: Path) -> None:
40
+ command = shortcuts.windows_shortcut_command(tmp_path / "bin")
41
+
42
+ assert "WScript.Shell" in command
43
+ assert "pyControl GUI.lnk" in command
44
+ assert str(tmp_path / "bin" / "pycontrol-gui.exe") in command
45
+ assert "Write-Output $link" in command
46
+
47
+
48
+ def test_linux_desktop_entry_written_under_home(tmp_path: Path) -> None:
49
+ bin_dir = tmp_path / "bin"
50
+ home = tmp_path / "home"
51
+
52
+ entry = shortcuts.create_linux_desktop_entry(bin_dir, home)
53
+
54
+ assert entry == home / ".local" / "share" / "applications" / "pycontrol-gui.desktop"
55
+ text = entry.read_text(encoding="utf-8")
56
+ assert "[Desktop Entry]" in text
57
+ assert f'Exec="{bin_dir / "pycontrol-gui"}"' in text
58
+ assert "Terminal=false" in text
59
+
60
+
61
+ def test_create_shortcut_dispatches_by_platform(tmp_path: Path) -> None:
62
+ bin_dir = tmp_path / "bin"
63
+ home = tmp_path / "home"
64
+
65
+ assert shortcuts.create_shortcut(bin_dir, home, platform="darwin") == home / "Applications" / "pyControl GUI.app"
66
+ assert (
67
+ shortcuts.create_shortcut(bin_dir, home, platform="linux")
68
+ == home / ".local" / "share" / "applications" / "pycontrol-gui.desktop"
69
+ )
70
+ assert shortcuts.create_shortcut(bin_dir, home, platform="sunos") is None
@@ -0,0 +1,108 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "packaging"
25
+ version = "26.2"
26
+ source = { registry = "https://pypi.org/simple" }
27
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
28
+ wheels = [
29
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
30
+ ]
31
+
32
+ [[package]]
33
+ name = "pluggy"
34
+ version = "1.6.0"
35
+ source = { registry = "https://pypi.org/simple" }
36
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "pycontrol-install"
43
+ version = "0.1.0a3"
44
+ source = { editable = "." }
45
+
46
+ [package.dev-dependencies]
47
+ dev = [
48
+ { name = "pytest" },
49
+ { name = "ruff" },
50
+ ]
51
+
52
+ [package.metadata]
53
+
54
+ [package.metadata.requires-dev]
55
+ dev = [
56
+ { name = "pytest", specifier = ">=8" },
57
+ { name = "ruff", specifier = ">=0.5" },
58
+ ]
59
+
60
+ [[package]]
61
+ name = "pygments"
62
+ version = "2.20.0"
63
+ source = { registry = "https://pypi.org/simple" }
64
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
65
+ wheels = [
66
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
67
+ ]
68
+
69
+ [[package]]
70
+ name = "pytest"
71
+ version = "9.1.0"
72
+ source = { registry = "https://pypi.org/simple" }
73
+ dependencies = [
74
+ { name = "colorama", marker = "sys_platform == 'win32'" },
75
+ { name = "iniconfig" },
76
+ { name = "packaging" },
77
+ { name = "pluggy" },
78
+ { name = "pygments" },
79
+ ]
80
+ sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
81
+ wheels = [
82
+ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
83
+ ]
84
+
85
+ [[package]]
86
+ name = "ruff"
87
+ version = "0.15.17"
88
+ source = { registry = "https://pypi.org/simple" }
89
+ sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
90
+ wheels = [
91
+ { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
92
+ { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
93
+ { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
94
+ { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
95
+ { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
96
+ { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
97
+ { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
98
+ { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
99
+ { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
100
+ { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
101
+ { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
102
+ { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
103
+ { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
104
+ { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
105
+ { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
106
+ { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
107
+ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
108
+ ]