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.
- pycontrol_install-0.1.0a1/.gitignore +58 -0
- pycontrol_install-0.1.0a1/LICENSE.txt +8 -0
- pycontrol_install-0.1.0a1/PKG-INFO +122 -0
- pycontrol_install-0.1.0a1/README.md +104 -0
- pycontrol_install-0.1.0a1/pyproject.toml +58 -0
- pycontrol_install-0.1.0a1/src/pycontrol_install/__init__.py +5 -0
- pycontrol_install-0.1.0a1/src/pycontrol_install/cli.py +334 -0
- pycontrol_install-0.1.0a1/src/pycontrol_install/shortcuts.py +116 -0
- pycontrol_install-0.1.0a1/tests/test_cli.py +266 -0
- pycontrol_install-0.1.0a1/tests/test_shortcuts.py +70 -0
- pycontrol_install-0.1.0a1/uv.lock +108 -0
|
@@ -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,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
|
+
]
|