pydblclick 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydblclick-0.2.0/LICENSE.md +21 -0
- pydblclick-0.2.0/PKG-INFO +200 -0
- pydblclick-0.2.0/README.md +182 -0
- pydblclick-0.2.0/pydblclick/__init__.py +0 -0
- pydblclick-0.2.0/pydblclick/__main__.py +208 -0
- pydblclick-0.2.0/pydblclick/_child.py +398 -0
- pydblclick-0.2.0/pydblclick/_cli.py +299 -0
- pydblclick-0.2.0/pydblclick/_script_meta.py +108 -0
- pydblclick-0.2.0/pydblclick/winpyfiles/__init__.py +22 -0
- pydblclick-0.2.0/pydblclick/winpyfiles/__main__.py +295 -0
- pydblclick-0.2.0/pydblclick/winpyfiles/_assoc.py +242 -0
- pydblclick-0.2.0/pydblclick/winpyfiles/_backup.py +77 -0
- pydblclick-0.2.0/pydblclick/winpyfiles/_elevation.py +25 -0
- pydblclick-0.2.0/pydblclick/winpyfiles/_registry.py +66 -0
- pydblclick-0.2.0/pydblclick.egg-info/PKG-INFO +200 -0
- pydblclick-0.2.0/pydblclick.egg-info/SOURCES.txt +24 -0
- pydblclick-0.2.0/pydblclick.egg-info/dependency_links.txt +1 -0
- pydblclick-0.2.0/pydblclick.egg-info/entry_points.txt +2 -0
- pydblclick-0.2.0/pydblclick.egg-info/requires.txt +3 -0
- pydblclick-0.2.0/pydblclick.egg-info/top_level.txt +1 -0
- pydblclick-0.2.0/pyproject.toml +34 -0
- pydblclick-0.2.0/setup.cfg +4 -0
- pydblclick-0.2.0/tests/test_pydblclick.py +298 -0
- pydblclick-0.2.0/tests/test_script_meta.py +90 -0
- pydblclick-0.2.0/tests/test_subprocess.py +376 -0
- pydblclick-0.2.0/tests/test_system.py +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 TsKyrk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydblclick
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Enhances the user experience of Python scripts run via double-click on Windows
|
|
5
|
+
Author: TsKyrk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/TsKyrk/pydblclick
|
|
8
|
+
Keywords: windows,launcher,wrapper,double-click,console
|
|
9
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE.md
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# What is pydblclick ?
|
|
20
|
+
|
|
21
|
+
*(formerly known as **pyexewrap** — renamed before the first PyPI release, since the old
|
|
22
|
+
name referenced the deprecated `py.exe` launcher and suggested exe-building tools)*
|
|
23
|
+
|
|
24
|
+
pydblclick makes Python scripts pleasant to run **by double-click** on Windows — for you,
|
|
25
|
+
your colleagues, or anyone you share a one-file script with:
|
|
26
|
+
|
|
27
|
+
- The console window **never flashes away**: a pause prompt appears at the end of the
|
|
28
|
+
script, *including* when an exception occurs (even a syntax error), so the traceback
|
|
29
|
+
is always readable.
|
|
30
|
+
- Scripts that declare their dependencies inline ([PEP 723](https://peps.python.org/pep-0723/))
|
|
31
|
+
are executed through [uv](https://docs.astral.sh/uv/): **dependencies are resolved
|
|
32
|
+
automatically** in an ephemeral environment — the recipient never manages venvs or
|
|
33
|
+
`pip install`.
|
|
34
|
+
- An interactive menu at the pause prompt: `<i>` opens a Python console **with the
|
|
35
|
+
script's real variables** (post-mortem debugging), `<c>` opens a cmd console,
|
|
36
|
+
`<r>` restarts the script.
|
|
37
|
+
- `.pyw` (windowed) scripts run with **no console at all** (they are registered with
|
|
38
|
+
`pythonw.exe`) — but if they crash, a console is created on the spot showing the
|
|
39
|
+
script's output and the traceback, instead of dying silently.
|
|
40
|
+
- When a script is run from a console, called by another script or a batch file,
|
|
41
|
+
pydblclick stays out of the way: no pause, exit codes and arguments faithfully
|
|
42
|
+
propagated.
|
|
43
|
+
|
|
44
|
+
## Python's native problems for Windows users
|
|
45
|
+
|
|
46
|
+
- A double-clicked `.py` file pops a console that flashes away, unless the last line is
|
|
47
|
+
a blocking `input()` — and even then, any exception (a syntax error, a missing module)
|
|
48
|
+
skips that line and the window vanishes before the traceback can be read.
|
|
49
|
+
- That blocking `input()` becomes undesirable when the same script is run from a console
|
|
50
|
+
or called by another script.
|
|
51
|
+
- A `.pyw` script that crashes dies silently: there is no console to show the traceback.
|
|
52
|
+
- Sharing a script that needs `requests` or `pandas` means asking the recipient to
|
|
53
|
+
understand pip, venvs, and PATH — or it just crashes with `ModuleNotFoundError`.
|
|
54
|
+
|
|
55
|
+
# Installation
|
|
56
|
+
|
|
57
|
+
```commandline
|
|
58
|
+
pip install <path-to-this-repo> (PyPI package coming soon)
|
|
59
|
+
pydblclick register
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`pydblclick register` sets pydblclick as the default handler for `.py`/`.pyw` double-clicks
|
|
63
|
+
using the standard Windows mechanism (ProgID + UserChoice). This works on **all** Python
|
|
64
|
+
installations — classic installer *and* MSIX Python Manager (see
|
|
65
|
+
[MSIX_COMPATIBILITY.md](MSIX_COMPATIBILITY.md)). A backup of the previous file
|
|
66
|
+
associations is saved automatically before any change.
|
|
67
|
+
|
|
68
|
+
To undo everything:
|
|
69
|
+
|
|
70
|
+
```commandline
|
|
71
|
+
pydblclick unregister
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To inspect the Windows file association chain (and detect MSIX interference):
|
|
75
|
+
|
|
76
|
+
```commandline
|
|
77
|
+
pydblclick diagnose
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For automatic dependency resolution (PEP 723 scripts), also install
|
|
81
|
+
[uv](https://docs.astral.sh/uv/getting-started/installation/). Without uv, PEP 723
|
|
82
|
+
scripts still run with plain Python, and a message explains what to install.
|
|
83
|
+
|
|
84
|
+
# Usage
|
|
85
|
+
|
|
86
|
+
## Double-click (the main purpose)
|
|
87
|
+
|
|
88
|
+
Once registered, **every** `.py`/`.pyw` file you double-click runs enhanced. Nothing to
|
|
89
|
+
add to the scripts themselves. Try the scripts in the [examples](examples/) folder.
|
|
90
|
+
|
|
91
|
+
## Sharing dependency-aware one-file scripts (PEP 723 + uv)
|
|
92
|
+
|
|
93
|
+
Declare dependencies at the top of the script, in standard PEP 723 format:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# /// script
|
|
97
|
+
# requires-python = ">=3.11"
|
|
98
|
+
# dependencies = ["requests", "rich"]
|
|
99
|
+
# ///
|
|
100
|
+
import requests
|
|
101
|
+
...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
On a machine with pydblclick + uv, double-clicking this file "just works": uv resolves
|
|
105
|
+
the dependencies in an ephemeral environment, and pydblclick keeps the window open with
|
|
106
|
+
its usual menu. This is a standard format — the same file also runs with `uv run` alone
|
|
107
|
+
on any platform. Use `uv add --script myscript.py requests` to maintain the block.
|
|
108
|
+
|
|
109
|
+
## Opting a script out
|
|
110
|
+
|
|
111
|
+
Add this comment anywhere in a script to make pydblclick step aside (plain Python
|
|
112
|
+
behavior, no pause):
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# pydblclick: off
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Pause only on error
|
|
119
|
+
|
|
120
|
+
To make an individual script skip the final pause *unless an exception occurred*:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
pydblclick_customizations['must_pause_in_console'] = False
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Command line
|
|
127
|
+
|
|
128
|
+
`pydblclick <script.py> [args...]` (or `python -m pydblclick <script.py> [args...]`) wraps
|
|
129
|
+
a script explicitly. In a console there is no pause; set the
|
|
130
|
+
`pydblclick_simulate_doubleclick` env var to force double-click behavior (useful in
|
|
131
|
+
batch files and tests).
|
|
132
|
+
|
|
133
|
+
## Custom icons
|
|
134
|
+
|
|
135
|
+
Scripts launched via pydblclick show the registered Python icon. For a custom icon,
|
|
136
|
+
create a shortcut to the script (ALT+drag & drop) and set the icon in its properties.
|
|
137
|
+
|
|
138
|
+
# How it works
|
|
139
|
+
|
|
140
|
+
Two processes (see [CLAUDE.md](CLAUDE.md) architecture notes and [ROADMAP.md](ROADMAP.md)):
|
|
141
|
+
|
|
142
|
+
- a thin **parent supervisor** (`python -m pydblclick`) which guarantees the window
|
|
143
|
+
survives anything — even `os._exit()`, a native crash, or a script that closes stdin;
|
|
144
|
+
- a **child engine** (`python -m pydblclick._child`) which runs your script with exact
|
|
145
|
+
plain-Python semantics (`runpy`), shows clean tracebacks (no wrapper frames), and
|
|
146
|
+
owns the pause menu. For PEP 723 scripts the child runs inside the uv-provisioned
|
|
147
|
+
environment.
|
|
148
|
+
|
|
149
|
+
No monkey-patching, no code injection: `__name__`, `__file__`, `sys.argv`, exit codes
|
|
150
|
+
and `exit()`/`quit()` behave exactly as with plain Python.
|
|
151
|
+
|
|
152
|
+
# Legacy: the shebang method (deprecated)
|
|
153
|
+
|
|
154
|
+
Before the 2026 pivot (under the project's former name *pyexewrap*), scripts were enhanced
|
|
155
|
+
individually with a shebang line (`#!/usr/bin/env python -m pydblclick`) read by the classic
|
|
156
|
+
`py.exe` launcher, and installation went through a system-wide PYTHONPATH (the helper scripts
|
|
157
|
+
have since been removed). This mechanism **still works on classic-installer systems**
|
|
158
|
+
provided pydblclick is importable by the system Python (`pip install` does that), but it is
|
|
159
|
+
a dead end:
|
|
160
|
+
|
|
161
|
+
- the classic `py.exe` launcher is deprecated since Python 3.14 and will not be produced
|
|
162
|
+
for Python 3.16+;
|
|
163
|
+
- the MSIX Python Manager (Microsoft Store / "Python Install Manager" on python.org)
|
|
164
|
+
never reads shebangs on double-click, and its shebang support
|
|
165
|
+
[does not allow arguments](https://docs.python.org/3/using/windows.html) such as
|
|
166
|
+
`-m pydblclick`.
|
|
167
|
+
|
|
168
|
+
Use `pydblclick register` instead; per-script granularity is provided by the
|
|
169
|
+
`# pydblclick: off` directive. See [MSIX_COMPATIBILITY.md](MSIX_COMPATIBILITY.md) for the
|
|
170
|
+
full compatibility matrix and history.
|
|
171
|
+
|
|
172
|
+
# Compatibility with the MSIX Python Manager (python/pymanager)
|
|
173
|
+
|
|
174
|
+
The `PythonSoftwareFoundation.PythonManager` MSIX package intercepts `.py`/`.pyw`
|
|
175
|
+
double-clicks through Windows App Model activation, bypassing shebangs and registry
|
|
176
|
+
ftype settings. **`pydblclick register` works with it**: the MSIX launcher honors
|
|
177
|
+
UserChoice pointing to the `pydblclick.PyFile` ProgID (confirmed by testing).
|
|
178
|
+
|
|
179
|
+
If double-clicks don't reach pydblclick, run `pydblclick diagnose` — it detects MSIX
|
|
180
|
+
interference and tells you what to fix. Details in
|
|
181
|
+
[MSIX_COMPATIBILITY.md](MSIX_COMPATIBILITY.md).
|
|
182
|
+
|
|
183
|
+
# Note about py.exe
|
|
184
|
+
|
|
185
|
+
`py.exe` was the Windows wrapper for multiple Python interpreters, making pydblclick a
|
|
186
|
+
wrapper of a wrapper. Its pymanager successor confirms that launcher-level wrapping is
|
|
187
|
+
not extensible — which is why pydblclick now registers itself as the file handler, the
|
|
188
|
+
one mechanism every launcher must respect.
|
|
189
|
+
|
|
190
|
+
# Todos
|
|
191
|
+
|
|
192
|
+
- Publish to PyPI (`pip install pydblclick`)
|
|
193
|
+
- Standalone `pydblclick.exe` handler (no Python required to bootstrap; uv can even
|
|
194
|
+
provision Python itself)
|
|
195
|
+
- Offer to install uv when a PEP 723 script is double-clicked and uv is missing
|
|
196
|
+
- Context menu items "Run with pydblclick" / "Bypass pydblclick"
|
|
197
|
+
|
|
198
|
+
# Contributions
|
|
199
|
+
|
|
200
|
+
Your contributions would be greatly appreciated. Feel free to copy the project.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# What is pydblclick ?
|
|
2
|
+
|
|
3
|
+
*(formerly known as **pyexewrap** — renamed before the first PyPI release, since the old
|
|
4
|
+
name referenced the deprecated `py.exe` launcher and suggested exe-building tools)*
|
|
5
|
+
|
|
6
|
+
pydblclick makes Python scripts pleasant to run **by double-click** on Windows — for you,
|
|
7
|
+
your colleagues, or anyone you share a one-file script with:
|
|
8
|
+
|
|
9
|
+
- The console window **never flashes away**: a pause prompt appears at the end of the
|
|
10
|
+
script, *including* when an exception occurs (even a syntax error), so the traceback
|
|
11
|
+
is always readable.
|
|
12
|
+
- Scripts that declare their dependencies inline ([PEP 723](https://peps.python.org/pep-0723/))
|
|
13
|
+
are executed through [uv](https://docs.astral.sh/uv/): **dependencies are resolved
|
|
14
|
+
automatically** in an ephemeral environment — the recipient never manages venvs or
|
|
15
|
+
`pip install`.
|
|
16
|
+
- An interactive menu at the pause prompt: `<i>` opens a Python console **with the
|
|
17
|
+
script's real variables** (post-mortem debugging), `<c>` opens a cmd console,
|
|
18
|
+
`<r>` restarts the script.
|
|
19
|
+
- `.pyw` (windowed) scripts run with **no console at all** (they are registered with
|
|
20
|
+
`pythonw.exe`) — but if they crash, a console is created on the spot showing the
|
|
21
|
+
script's output and the traceback, instead of dying silently.
|
|
22
|
+
- When a script is run from a console, called by another script or a batch file,
|
|
23
|
+
pydblclick stays out of the way: no pause, exit codes and arguments faithfully
|
|
24
|
+
propagated.
|
|
25
|
+
|
|
26
|
+
## Python's native problems for Windows users
|
|
27
|
+
|
|
28
|
+
- A double-clicked `.py` file pops a console that flashes away, unless the last line is
|
|
29
|
+
a blocking `input()` — and even then, any exception (a syntax error, a missing module)
|
|
30
|
+
skips that line and the window vanishes before the traceback can be read.
|
|
31
|
+
- That blocking `input()` becomes undesirable when the same script is run from a console
|
|
32
|
+
or called by another script.
|
|
33
|
+
- A `.pyw` script that crashes dies silently: there is no console to show the traceback.
|
|
34
|
+
- Sharing a script that needs `requests` or `pandas` means asking the recipient to
|
|
35
|
+
understand pip, venvs, and PATH — or it just crashes with `ModuleNotFoundError`.
|
|
36
|
+
|
|
37
|
+
# Installation
|
|
38
|
+
|
|
39
|
+
```commandline
|
|
40
|
+
pip install <path-to-this-repo> (PyPI package coming soon)
|
|
41
|
+
pydblclick register
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`pydblclick register` sets pydblclick as the default handler for `.py`/`.pyw` double-clicks
|
|
45
|
+
using the standard Windows mechanism (ProgID + UserChoice). This works on **all** Python
|
|
46
|
+
installations — classic installer *and* MSIX Python Manager (see
|
|
47
|
+
[MSIX_COMPATIBILITY.md](MSIX_COMPATIBILITY.md)). A backup of the previous file
|
|
48
|
+
associations is saved automatically before any change.
|
|
49
|
+
|
|
50
|
+
To undo everything:
|
|
51
|
+
|
|
52
|
+
```commandline
|
|
53
|
+
pydblclick unregister
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
To inspect the Windows file association chain (and detect MSIX interference):
|
|
57
|
+
|
|
58
|
+
```commandline
|
|
59
|
+
pydblclick diagnose
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For automatic dependency resolution (PEP 723 scripts), also install
|
|
63
|
+
[uv](https://docs.astral.sh/uv/getting-started/installation/). Without uv, PEP 723
|
|
64
|
+
scripts still run with plain Python, and a message explains what to install.
|
|
65
|
+
|
|
66
|
+
# Usage
|
|
67
|
+
|
|
68
|
+
## Double-click (the main purpose)
|
|
69
|
+
|
|
70
|
+
Once registered, **every** `.py`/`.pyw` file you double-click runs enhanced. Nothing to
|
|
71
|
+
add to the scripts themselves. Try the scripts in the [examples](examples/) folder.
|
|
72
|
+
|
|
73
|
+
## Sharing dependency-aware one-file scripts (PEP 723 + uv)
|
|
74
|
+
|
|
75
|
+
Declare dependencies at the top of the script, in standard PEP 723 format:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# /// script
|
|
79
|
+
# requires-python = ">=3.11"
|
|
80
|
+
# dependencies = ["requests", "rich"]
|
|
81
|
+
# ///
|
|
82
|
+
import requests
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
On a machine with pydblclick + uv, double-clicking this file "just works": uv resolves
|
|
87
|
+
the dependencies in an ephemeral environment, and pydblclick keeps the window open with
|
|
88
|
+
its usual menu. This is a standard format — the same file also runs with `uv run` alone
|
|
89
|
+
on any platform. Use `uv add --script myscript.py requests` to maintain the block.
|
|
90
|
+
|
|
91
|
+
## Opting a script out
|
|
92
|
+
|
|
93
|
+
Add this comment anywhere in a script to make pydblclick step aside (plain Python
|
|
94
|
+
behavior, no pause):
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# pydblclick: off
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Pause only on error
|
|
101
|
+
|
|
102
|
+
To make an individual script skip the final pause *unless an exception occurred*:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
pydblclick_customizations['must_pause_in_console'] = False
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Command line
|
|
109
|
+
|
|
110
|
+
`pydblclick <script.py> [args...]` (or `python -m pydblclick <script.py> [args...]`) wraps
|
|
111
|
+
a script explicitly. In a console there is no pause; set the
|
|
112
|
+
`pydblclick_simulate_doubleclick` env var to force double-click behavior (useful in
|
|
113
|
+
batch files and tests).
|
|
114
|
+
|
|
115
|
+
## Custom icons
|
|
116
|
+
|
|
117
|
+
Scripts launched via pydblclick show the registered Python icon. For a custom icon,
|
|
118
|
+
create a shortcut to the script (ALT+drag & drop) and set the icon in its properties.
|
|
119
|
+
|
|
120
|
+
# How it works
|
|
121
|
+
|
|
122
|
+
Two processes (see [CLAUDE.md](CLAUDE.md) architecture notes and [ROADMAP.md](ROADMAP.md)):
|
|
123
|
+
|
|
124
|
+
- a thin **parent supervisor** (`python -m pydblclick`) which guarantees the window
|
|
125
|
+
survives anything — even `os._exit()`, a native crash, or a script that closes stdin;
|
|
126
|
+
- a **child engine** (`python -m pydblclick._child`) which runs your script with exact
|
|
127
|
+
plain-Python semantics (`runpy`), shows clean tracebacks (no wrapper frames), and
|
|
128
|
+
owns the pause menu. For PEP 723 scripts the child runs inside the uv-provisioned
|
|
129
|
+
environment.
|
|
130
|
+
|
|
131
|
+
No monkey-patching, no code injection: `__name__`, `__file__`, `sys.argv`, exit codes
|
|
132
|
+
and `exit()`/`quit()` behave exactly as with plain Python.
|
|
133
|
+
|
|
134
|
+
# Legacy: the shebang method (deprecated)
|
|
135
|
+
|
|
136
|
+
Before the 2026 pivot (under the project's former name *pyexewrap*), scripts were enhanced
|
|
137
|
+
individually with a shebang line (`#!/usr/bin/env python -m pydblclick`) read by the classic
|
|
138
|
+
`py.exe` launcher, and installation went through a system-wide PYTHONPATH (the helper scripts
|
|
139
|
+
have since been removed). This mechanism **still works on classic-installer systems**
|
|
140
|
+
provided pydblclick is importable by the system Python (`pip install` does that), but it is
|
|
141
|
+
a dead end:
|
|
142
|
+
|
|
143
|
+
- the classic `py.exe` launcher is deprecated since Python 3.14 and will not be produced
|
|
144
|
+
for Python 3.16+;
|
|
145
|
+
- the MSIX Python Manager (Microsoft Store / "Python Install Manager" on python.org)
|
|
146
|
+
never reads shebangs on double-click, and its shebang support
|
|
147
|
+
[does not allow arguments](https://docs.python.org/3/using/windows.html) such as
|
|
148
|
+
`-m pydblclick`.
|
|
149
|
+
|
|
150
|
+
Use `pydblclick register` instead; per-script granularity is provided by the
|
|
151
|
+
`# pydblclick: off` directive. See [MSIX_COMPATIBILITY.md](MSIX_COMPATIBILITY.md) for the
|
|
152
|
+
full compatibility matrix and history.
|
|
153
|
+
|
|
154
|
+
# Compatibility with the MSIX Python Manager (python/pymanager)
|
|
155
|
+
|
|
156
|
+
The `PythonSoftwareFoundation.PythonManager` MSIX package intercepts `.py`/`.pyw`
|
|
157
|
+
double-clicks through Windows App Model activation, bypassing shebangs and registry
|
|
158
|
+
ftype settings. **`pydblclick register` works with it**: the MSIX launcher honors
|
|
159
|
+
UserChoice pointing to the `pydblclick.PyFile` ProgID (confirmed by testing).
|
|
160
|
+
|
|
161
|
+
If double-clicks don't reach pydblclick, run `pydblclick diagnose` — it detects MSIX
|
|
162
|
+
interference and tells you what to fix. Details in
|
|
163
|
+
[MSIX_COMPATIBILITY.md](MSIX_COMPATIBILITY.md).
|
|
164
|
+
|
|
165
|
+
# Note about py.exe
|
|
166
|
+
|
|
167
|
+
`py.exe` was the Windows wrapper for multiple Python interpreters, making pydblclick a
|
|
168
|
+
wrapper of a wrapper. Its pymanager successor confirms that launcher-level wrapping is
|
|
169
|
+
not extensible — which is why pydblclick now registers itself as the file handler, the
|
|
170
|
+
one mechanism every launcher must respect.
|
|
171
|
+
|
|
172
|
+
# Todos
|
|
173
|
+
|
|
174
|
+
- Publish to PyPI (`pip install pydblclick`)
|
|
175
|
+
- Standalone `pydblclick.exe` handler (no Python required to bootstrap; uv can even
|
|
176
|
+
provision Python itself)
|
|
177
|
+
- Offer to install uv when a PEP 723 script is double-clicked and uv is missing
|
|
178
|
+
- Context menu items "Run with pydblclick" / "Bypass pydblclick"
|
|
179
|
+
|
|
180
|
+
# Contributions
|
|
181
|
+
|
|
182
|
+
Your contributions would be greatly appreciated. Feel free to copy the project.
|
|
File without changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""pydblclick — parent supervisor process.
|
|
2
|
+
|
|
3
|
+
Entry point: python -m pydblclick <script.py> [args...]
|
|
4
|
+
|
|
5
|
+
The actual script execution happens in a child process (pydblclick/_child.py)
|
|
6
|
+
launched with the same interpreter. The child runs the script with plain-Python
|
|
7
|
+
semantics, shows tracebacks and displays the pause prompt/menu itself.
|
|
8
|
+
|
|
9
|
+
The parent's only job is to guarantee that the console window never flashes
|
|
10
|
+
away, even when the child cannot pause by itself:
|
|
11
|
+
- the script closed stdin with exit()/quit() (input() becomes impossible),
|
|
12
|
+
- the interpreter died hard (os._exit, native crash, MemoryError...),
|
|
13
|
+
- the script was Ctrl+C'd to death.
|
|
14
|
+
|
|
15
|
+
Child -> parent protocol: the child writes "handled" to the file pointed to
|
|
16
|
+
by the PYDBLCLICK_STATUS_FILE env var once it has fulfilled its pause-or-no-pause
|
|
17
|
+
duty. If the marker is missing after the child exits, the parent pauses.
|
|
18
|
+
|
|
19
|
+
Before launching, the parent inspects the script's source (pydblclick/_script_meta.py):
|
|
20
|
+
- `# pydblclick: off` -> run with plain Python, no wrapping at all;
|
|
21
|
+
- PEP 723 `# /// script` block -> run the child through `uv run` so the
|
|
22
|
+
declared dependencies are resolved in an ephemeral environment.
|
|
23
|
+
"""
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
|
|
31
|
+
from pydblclick import _script_meta
|
|
32
|
+
from pydblclick._child import STATUS_HANDLED, User32, ensure_console, have_console, signed32
|
|
33
|
+
|
|
34
|
+
UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/"
|
|
35
|
+
|
|
36
|
+
# Exit code of a process killed because its console window was closed (or by a
|
|
37
|
+
# hard Ctrl+C/Ctrl+Break). Closing the window is a deliberate user action:
|
|
38
|
+
# the fallback pause must not fire for it.
|
|
39
|
+
STATUS_CONTROL_C_EXIT = 0xC000013A # 3221225786
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _console_python():
|
|
43
|
+
"""The console interpreter (python.exe) even when running under pythonw.exe.
|
|
44
|
+
|
|
45
|
+
The parent of a windowless .pyw launch is pythonw.exe, but the child engine
|
|
46
|
+
needs a standard interpreter with working standard streams.
|
|
47
|
+
"""
|
|
48
|
+
exe = sys.executable
|
|
49
|
+
if os.path.basename(exe).lower() == "pythonw.exe":
|
|
50
|
+
candidate = os.path.join(os.path.dirname(exe), "python.exe")
|
|
51
|
+
if os.path.exists(candidate):
|
|
52
|
+
return candidate
|
|
53
|
+
return exe
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _script_is_doubleclicked():
|
|
57
|
+
return (('PROMPT' not in os.environ)
|
|
58
|
+
or ('pydblclick_simulate_doubleclick' in os.environ)
|
|
59
|
+
or ('pyexewrap_simulate_doubleclick' in os.environ)) # legacy name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_status(status_file):
|
|
63
|
+
try:
|
|
64
|
+
with open(status_file, encoding="UTF-8") as f:
|
|
65
|
+
return f.read().strip()
|
|
66
|
+
except OSError:
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fallback_pause(returncode):
|
|
71
|
+
"""Last-resort pause when the child could not display its own prompt."""
|
|
72
|
+
if sys.stdout is None or sys.stdin is None:
|
|
73
|
+
# Windowless parent (pythonw.exe): no usable stdio at all -- create a
|
|
74
|
+
# console on the spot so the failure is visible.
|
|
75
|
+
if not ensure_console(title="pydblclick"):
|
|
76
|
+
return
|
|
77
|
+
elif have_console():
|
|
78
|
+
# The console may still be hidden if a .pyw script crashed hard
|
|
79
|
+
User32.show_window(User32.Const.SW_SHOWDEFAULT)
|
|
80
|
+
if returncode != 0:
|
|
81
|
+
print("\nThe script ended (exit code " + str(returncode) + ") without pydblclick being able to pause.")
|
|
82
|
+
try:
|
|
83
|
+
input("Press <Enter> to Quit.\n")
|
|
84
|
+
except (EOFError, ValueError, KeyboardInterrupt):
|
|
85
|
+
pass # stdin unusable in the parent too: nothing more we can do
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _plain_python_for(script):
|
|
89
|
+
"""The interpreter for unwrapped execution (pythonw for .pyw when available)."""
|
|
90
|
+
if os.path.splitext(script)[1].lower() == ".pyw":
|
|
91
|
+
pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
|
|
92
|
+
if os.path.exists(pythonw):
|
|
93
|
+
return pythonw
|
|
94
|
+
return sys.executable
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _find_uv():
|
|
98
|
+
"""Locate the uv executable (PYDBLCLICK_UV overrides PATH, for tests)."""
|
|
99
|
+
return os.environ.get("PYDBLCLICK_UV") or shutil.which("uv")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_child_command(script, script_args, env):
|
|
103
|
+
"""Build the child command line, delegating to `uv run` for PEP 723 scripts."""
|
|
104
|
+
default_cmd = [_console_python(), "-m", "pydblclick._child", script] + script_args
|
|
105
|
+
|
|
106
|
+
meta = _script_meta.parse_pep723(_script_meta.read_script_text(script))
|
|
107
|
+
if meta is None:
|
|
108
|
+
return default_cmd
|
|
109
|
+
|
|
110
|
+
uv = _find_uv()
|
|
111
|
+
if not uv:
|
|
112
|
+
print("[pydblclick] This script declares PEP 723 dependencies, but 'uv' was not found on PATH.")
|
|
113
|
+
print(" Install uv to run it with its dependencies resolved automatically:")
|
|
114
|
+
print(" " + UV_INSTALL_URL)
|
|
115
|
+
print(" Running with plain Python instead...\n")
|
|
116
|
+
return default_cmd
|
|
117
|
+
|
|
118
|
+
cmd = [uv, "run", "--no-project"]
|
|
119
|
+
if meta["requires-python"]:
|
|
120
|
+
cmd += ["--python", meta["requires-python"]]
|
|
121
|
+
for dep in meta["dependencies"]:
|
|
122
|
+
cmd += ["--with", dep]
|
|
123
|
+
cmd += ["python", "-m", "pydblclick._child", script] + script_args
|
|
124
|
+
|
|
125
|
+
# pydblclick itself must be importable inside uv's ephemeral environment
|
|
126
|
+
package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
127
|
+
existing = env.get("PYTHONPATH")
|
|
128
|
+
env["PYTHONPATH"] = package_parent + (os.pathsep + existing if existing else "")
|
|
129
|
+
return cmd
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main():
|
|
133
|
+
if len(sys.argv) < 2:
|
|
134
|
+
print("Usage: pydblclick <script.py> [args...]")
|
|
135
|
+
print(" pydblclick register (set pydblclick as the .py/.pyw double-click handler)")
|
|
136
|
+
print(" pydblclick unregister (restore plain Python on double-click)")
|
|
137
|
+
print(" pydblclick diagnose (inspect the Windows file association chain)")
|
|
138
|
+
return 2
|
|
139
|
+
|
|
140
|
+
# Management subcommands (a real script file named e.g. 'register' still wins)
|
|
141
|
+
from pydblclick._cli import COMMANDS
|
|
142
|
+
if sys.argv[1] in COMMANDS and not os.path.exists(sys.argv[1]):
|
|
143
|
+
from pydblclick import _cli
|
|
144
|
+
return _cli.main(sys.argv[1:])
|
|
145
|
+
|
|
146
|
+
script, script_args = sys.argv[1], sys.argv[2:]
|
|
147
|
+
|
|
148
|
+
# Per-script opt-out: run with plain Python, no wrapping, no pause
|
|
149
|
+
if _script_meta.has_opt_out(_script_meta.read_script_text(script)):
|
|
150
|
+
result = subprocess.run([_plain_python_for(script), script] + script_args)
|
|
151
|
+
return signed32(result.returncode)
|
|
152
|
+
|
|
153
|
+
# The status file is how the child tells us "I already paused (or decided
|
|
154
|
+
# a pause was not needed)". It survives any way the child may die.
|
|
155
|
+
fd, status_file = tempfile.mkstemp(prefix="pydblclick_status_")
|
|
156
|
+
os.close(fd)
|
|
157
|
+
env = dict(os.environ)
|
|
158
|
+
env["PYDBLCLICK_STATUS_FILE"] = status_file
|
|
159
|
+
|
|
160
|
+
cmd = _build_child_command(script, script_args, env)
|
|
161
|
+
|
|
162
|
+
# Windowless mode: a double-clicked .pyw arrives here through pythonw.exe,
|
|
163
|
+
# so this parent has no console. The child runs fully detached (no console
|
|
164
|
+
# either), its output captured in a log file. Only if an exception occurs
|
|
165
|
+
# does the child create a console (AllocConsole) and replay the log there.
|
|
166
|
+
windowless = os.path.splitext(script)[1].lower() == ".pyw" and not have_console()
|
|
167
|
+
run_kwargs = {}
|
|
168
|
+
log_file = None
|
|
169
|
+
log_handle = None
|
|
170
|
+
if windowless:
|
|
171
|
+
fd, log_file = tempfile.mkstemp(prefix="pydblclick_pyw_", suffix=".log")
|
|
172
|
+
log_handle = os.fdopen(fd, "w", encoding="utf-8", errors="replace")
|
|
173
|
+
env["PYDBLCLICK_PYW_LOG"] = log_file
|
|
174
|
+
run_kwargs = {
|
|
175
|
+
"stdin": subprocess.DEVNULL,
|
|
176
|
+
"stdout": log_handle,
|
|
177
|
+
"stderr": subprocess.STDOUT,
|
|
178
|
+
"creationflags": subprocess.DETACHED_PROCESS,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Ctrl+C is sent to every process attached to the console. The child is
|
|
182
|
+
# the one that must handle it (KeyboardInterrupt in the script, then its
|
|
183
|
+
# pause menu); the parent must survive to display the fallback pause.
|
|
184
|
+
previous_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
185
|
+
try:
|
|
186
|
+
result = subprocess.run(cmd, env=env, **run_kwargs)
|
|
187
|
+
finally:
|
|
188
|
+
signal.signal(signal.SIGINT, previous_handler)
|
|
189
|
+
if log_handle:
|
|
190
|
+
log_handle.close()
|
|
191
|
+
|
|
192
|
+
child_handled = _read_status(status_file) == STATUS_HANDLED
|
|
193
|
+
for temp_file in (status_file, log_file):
|
|
194
|
+
if temp_file:
|
|
195
|
+
try:
|
|
196
|
+
os.remove(temp_file)
|
|
197
|
+
except OSError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
user_closed_console = result.returncode == STATUS_CONTROL_C_EXIT
|
|
201
|
+
if not child_handled and not user_closed_console and _script_is_doubleclicked():
|
|
202
|
+
_fallback_pause(result.returncode)
|
|
203
|
+
|
|
204
|
+
return signed32(result.returncode)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(signed32(main()))
|