pyhabitat 1.0.25__tar.gz → 1.0.29__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.
Potentially problematic release.
This version of pyhabitat might be problematic. Click here for more details.
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/PKG-INFO +1 -1
- pyhabitat-1.0.29/pyhabitat/__init__.py +68 -0
- pyhabitat-1.0.29/pyhabitat/cli.py +73 -0
- pyhabitat-1.0.29/pyhabitat/environment.py +907 -0
- pyhabitat-1.0.29/pyhabitat/utils.py +25 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29/pyhabitat-build}/pyhabitat/__init__.py +1 -1
- {pyhabitat-1.0.25 → pyhabitat-1.0.29/pyhabitat-build}/pyhabitat/cli.py +19 -6
- {pyhabitat-1.0.25 → pyhabitat-1.0.29/pyhabitat-build}/pyhabitat/utils.py +17 -2
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/pyhabitat.egg-info/PKG-INFO +1 -1
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/pyhabitat.egg-info/SOURCES.txt +5 -1
- pyhabitat-1.0.29/pyhabitat.egg-info/top_level.txt +2 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/pyproject.toml +1 -1
- pyhabitat-1.0.25/pyhabitat.egg-info/top_level.txt +0 -1
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/LICENSE +0 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/README.md +0 -0
- {pyhabitat-1.0.25/pyhabitat → pyhabitat-1.0.29/pyhabitat-build}/__main__.py +0 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29/pyhabitat-build}/pyhabitat/environment.py +0 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/pyhabitat.egg-info/dependency_links.txt +0 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/pyhabitat.egg-info/entry_points.txt +0 -0
- {pyhabitat-1.0.25 → pyhabitat-1.0.29}/setup.cfg +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ./__init__.py
|
|
2
|
+
from .utils import get_version
|
|
3
|
+
from .environment import (
|
|
4
|
+
matplotlib_is_available_for_gui_plotting,
|
|
5
|
+
matplotlib_is_available_for_headless_image_export,
|
|
6
|
+
tkinter_is_available,
|
|
7
|
+
in_repl,
|
|
8
|
+
on_freebsd,
|
|
9
|
+
on_linux,
|
|
10
|
+
on_android,
|
|
11
|
+
on_windows,
|
|
12
|
+
on_wsl,
|
|
13
|
+
on_apple,
|
|
14
|
+
on_termux,
|
|
15
|
+
on_pydroid,
|
|
16
|
+
on_ish_alpine,
|
|
17
|
+
as_pyinstaller,
|
|
18
|
+
as_frozen,
|
|
19
|
+
is_elf,
|
|
20
|
+
is_pyz,
|
|
21
|
+
is_windows_portable_executable,
|
|
22
|
+
is_macos_executable,
|
|
23
|
+
is_pipx,
|
|
24
|
+
is_python_script,
|
|
25
|
+
interactive_terminal_is_available,
|
|
26
|
+
web_browser_is_available,
|
|
27
|
+
edit_textfile,
|
|
28
|
+
interp_path,
|
|
29
|
+
main,
|
|
30
|
+
user_darrin_deyoung,
|
|
31
|
+
can_read_input,
|
|
32
|
+
can_spawn_shell,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Optional: Set __all__ for explicit documentation and cleaner imports
|
|
36
|
+
__all__ = [
|
|
37
|
+
'matplotlib_is_available_for_gui_plotting',
|
|
38
|
+
'matplotlib_is_available_for_headless_image_export',
|
|
39
|
+
'tkinter_is_available',
|
|
40
|
+
'in_repl',
|
|
41
|
+
'on_termux',
|
|
42
|
+
'on_pydroid',
|
|
43
|
+
'on_wsl',
|
|
44
|
+
'on_freebsd',
|
|
45
|
+
'on_linux',
|
|
46
|
+
'on_android',
|
|
47
|
+
'on_windows',
|
|
48
|
+
'on_apple',
|
|
49
|
+
'on_ish_alpine',
|
|
50
|
+
'as_pyinstaller',
|
|
51
|
+
'as_frozen',
|
|
52
|
+
'is_elf',
|
|
53
|
+
'is_pyz',
|
|
54
|
+
'is_windows_portable_executable',
|
|
55
|
+
'is_macos_executable',
|
|
56
|
+
'is_pipx',
|
|
57
|
+
'is_python_script',
|
|
58
|
+
'interactive_terminal_is_available',
|
|
59
|
+
'web_browser_is_available',
|
|
60
|
+
'edit_textfile',
|
|
61
|
+
'interp_path',
|
|
62
|
+
'main',
|
|
63
|
+
'user_darrin_deyoung',
|
|
64
|
+
'can_read_input',
|
|
65
|
+
'can_spawn_shell',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
__version__ = get_version()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .environment import main
|
|
4
|
+
from .environment import * # to enable CLI --list
|
|
5
|
+
from .utils import get_version
|
|
6
|
+
#import __init__ as pyhabitat # works if everything is in root, v1.0.28
|
|
7
|
+
import pyhabitat # refers to the folder
|
|
8
|
+
|
|
9
|
+
def run_cli():
|
|
10
|
+
"""Parse CLI arguments and run the pyhabitat environment report."""
|
|
11
|
+
current_version = get_version()
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="PyHabitat: Python environment and build introspection"
|
|
14
|
+
)
|
|
15
|
+
# Add the version argument
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
'-v', '--version',
|
|
18
|
+
action='version',
|
|
19
|
+
version=f'PyHabitat {current_version}'
|
|
20
|
+
)
|
|
21
|
+
# Add the path argument
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--path",
|
|
24
|
+
type=str,
|
|
25
|
+
default=None,
|
|
26
|
+
help="Path to a script or binary to inspect (defaults to sys.argv[0])",
|
|
27
|
+
)
|
|
28
|
+
# Add the debug argument
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--debug",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="Enable verbose debug output",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--list",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="List available callable functions in pyhabitat"
|
|
38
|
+
)
|
|
39
|
+
#parser.add_argument(
|
|
40
|
+
# "--verbose",
|
|
41
|
+
# action="store_true",
|
|
42
|
+
# help="List available callable functions in pyhabitat"
|
|
43
|
+
#)
|
|
44
|
+
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"command",
|
|
47
|
+
nargs="?",
|
|
48
|
+
help="Function name to run (or use --list)",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
if args.list:
|
|
55
|
+
for name in pyhabitat.__all__:
|
|
56
|
+
func = getattr(pyhabitat, name, None)
|
|
57
|
+
if callable(func):
|
|
58
|
+
print(name)
|
|
59
|
+
if args.debug:
|
|
60
|
+
doc = func.__doc__ or "(no description)"
|
|
61
|
+
print(f"{name}: {doc}")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if args.command:
|
|
65
|
+
func = getattr(pyhabitat, args.command, None)
|
|
66
|
+
if callable(func):
|
|
67
|
+
print(func())
|
|
68
|
+
return # Exit after running the subcommand
|
|
69
|
+
else:
|
|
70
|
+
print(f"Unknown function: {args.command}")
|
|
71
|
+
return # Exit after reporting the unknown command
|
|
72
|
+
|
|
73
|
+
main(path=Path(args.path) if args.path else None, debug=args.debug)
|
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
'''
|
|
2
|
+
Title: environment.py
|
|
3
|
+
Author: Clayton Bennett
|
|
4
|
+
Created: 23 July 2024
|
|
5
|
+
'''
|
|
6
|
+
from __future__ import annotations # Delays annotation evaluation, allowing modern 3.10+ type syntax and forward references in older Python versions 3.8 and 3.9
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import webbrowser
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import subprocess
|
|
14
|
+
import io
|
|
15
|
+
import zipfile
|
|
16
|
+
import logging
|
|
17
|
+
import getpass
|
|
18
|
+
import select
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
'matplotlib_is_available_for_gui_plotting',
|
|
22
|
+
'matplotlib_is_available_for_headless_image_export',
|
|
23
|
+
'tkinter_is_available',
|
|
24
|
+
'on_termux',
|
|
25
|
+
'on_freebsd',
|
|
26
|
+
'on_linux',
|
|
27
|
+
'on_pydroid',
|
|
28
|
+
'on_android',
|
|
29
|
+
'on_windows',
|
|
30
|
+
'on_wsl',
|
|
31
|
+
'on_apple',
|
|
32
|
+
'on_ish_alpine',
|
|
33
|
+
'as_pyinstaller',
|
|
34
|
+
'as_frozen',
|
|
35
|
+
'is_elf',
|
|
36
|
+
'is_pyz',
|
|
37
|
+
'is_windows_portable_executable',
|
|
38
|
+
'is_macos_executable',
|
|
39
|
+
'is_pipx',
|
|
40
|
+
'interactive_terminal_is_available',
|
|
41
|
+
'web_browser_is_available',
|
|
42
|
+
'edit_textfile',
|
|
43
|
+
'in_repl',
|
|
44
|
+
'interp_path',
|
|
45
|
+
'main',
|
|
46
|
+
'user_darrin_deyoung',
|
|
47
|
+
'can_read_input',
|
|
48
|
+
'can_spawn_shell',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Global cache for tkinter and matplotlib (mpl) availability
|
|
52
|
+
_TKINTER_AVAILABILITY: bool | None = None
|
|
53
|
+
_MATPLOTLIB_EXPORT_AVAILABILITY: bool | None = None
|
|
54
|
+
_MATPLOTLIB_WINDOWED_AVAILABILITY: bool | None = None
|
|
55
|
+
_CAN_SPAWN_SHELL: bool | None = None
|
|
56
|
+
_CAN_READ_INPUT: bool | None = None
|
|
57
|
+
|
|
58
|
+
# --- GUI CHECKS ---
|
|
59
|
+
def matplotlib_is_available_for_gui_plotting(termux_has_gui=False):
|
|
60
|
+
"""Check if Matplotlib is available AND can use a GUI backend for a popup window."""
|
|
61
|
+
global _MATPLOTLIB_WINDOWED_AVAILABILITY
|
|
62
|
+
|
|
63
|
+
if _MATPLOTLIB_WINDOWED_AVAILABILITY is not None:
|
|
64
|
+
return _MATPLOTLIB_WINDOWED_AVAILABILITY
|
|
65
|
+
|
|
66
|
+
# 1. Termux exclusion check (assume no X11/GUI)
|
|
67
|
+
# Exclude Termux UNLESS the user explicitly provides termux_has_gui=True.
|
|
68
|
+
if on_termux() and not termux_has_gui:
|
|
69
|
+
_MATPLOTLIB_WINDOWED_AVAILABILITY = False
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# 2. Tkinter check (The most definitive check for a working display environment)
|
|
73
|
+
# If tkinter can't open a window, Matplotlib's TkAgg backend will fail.
|
|
74
|
+
if not tkinter_is_available():
|
|
75
|
+
_MATPLOTLIB_WINDOWED_AVAILABILITY = False
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
# 3. Matplotlib + TkAgg check
|
|
79
|
+
try:
|
|
80
|
+
import matplotlib
|
|
81
|
+
# Force the common GUI backend. At this point, we know tkinter is *available*.
|
|
82
|
+
# # 'TkAgg' is often the most reliable cross-platform test.
|
|
83
|
+
# 'TkAgg' != 'Agg'. The Agg backend is for non-gui image export.
|
|
84
|
+
if matplotlib.get_backend().lower() != 'tkagg':
|
|
85
|
+
matplotlib.use('TkAgg', force=True)
|
|
86
|
+
import matplotlib.pyplot as plt
|
|
87
|
+
# A simple test call to ensure the backend initializes
|
|
88
|
+
# This final test catches any edge cases where tkinter is present but
|
|
89
|
+
# Matplotlib's *integration* with it is broken
|
|
90
|
+
plt.figure()
|
|
91
|
+
plt.close()
|
|
92
|
+
|
|
93
|
+
_MATPLOTLIB_WINDOWED_AVAILABILITY = True
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
except Exception:
|
|
97
|
+
# Catches Matplotlib ImportError or any runtime error from the plt.figure() call
|
|
98
|
+
_MATPLOTLIB_WINDOWED_AVAILABILITY = False
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def matplotlib_is_available_for_headless_image_export():
|
|
103
|
+
"""Check if Matplotlib is available AND can use the Agg backend for image export."""
|
|
104
|
+
global _MATPLOTLIB_EXPORT_AVAILABILITY
|
|
105
|
+
|
|
106
|
+
if _MATPLOTLIB_EXPORT_AVAILABILITY is not None:
|
|
107
|
+
return _MATPLOTLIB_EXPORT_AVAILABILITY
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
import matplotlib
|
|
111
|
+
# The Agg backend (for PNG/JPEG export) is very basic and usually available
|
|
112
|
+
# if the core library is installed. We explicitly set it just in case.
|
|
113
|
+
# 'Agg' != 'TkAgg'. The TkAgg backend is for interactive gui image display.
|
|
114
|
+
matplotlib.use('Agg', force=True)
|
|
115
|
+
import matplotlib.pyplot as plt
|
|
116
|
+
|
|
117
|
+
# A simple test to ensure a figure can be generated
|
|
118
|
+
plt.figure()
|
|
119
|
+
# Ensure it can save to an in-memory buffer (to avoid disk access issues)
|
|
120
|
+
buf = io.BytesIO()
|
|
121
|
+
plt.savefig(buf, format='png')
|
|
122
|
+
plt.close()
|
|
123
|
+
|
|
124
|
+
_MATPLOTLIB_EXPORT_AVAILABILITY = True
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
except Exception:
|
|
128
|
+
_MATPLOTLIB_EXPORT_AVAILABILITY = False
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def tkinter_is_available() -> bool:
|
|
132
|
+
"""Check if tkinter is available and can successfully connect to a display."""
|
|
133
|
+
global _TKINTER_AVAILABILITY
|
|
134
|
+
|
|
135
|
+
# 1. Return cached result if already calculated
|
|
136
|
+
if _TKINTER_AVAILABILITY is not None:
|
|
137
|
+
return _TKINTER_AVAILABILITY
|
|
138
|
+
|
|
139
|
+
# 2. Perform the full, definitive check
|
|
140
|
+
try:
|
|
141
|
+
import tkinter as tk
|
|
142
|
+
|
|
143
|
+
# Perform the actual GUI backend test for absolute certainty.
|
|
144
|
+
# This only runs once per script execution.
|
|
145
|
+
root = tk.Tk()
|
|
146
|
+
root.withdraw()
|
|
147
|
+
root.update()
|
|
148
|
+
root.destroy()
|
|
149
|
+
|
|
150
|
+
_TKINTER_AVAILABILITY = True
|
|
151
|
+
return True
|
|
152
|
+
except Exception:
|
|
153
|
+
# Fails if: tkinter module is missing OR the display backend is unavailable
|
|
154
|
+
_TKINTER_AVAILABILITY = False
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# --- ENVIRONMENT AND OPERATING SYSTEM CHECKS ---
|
|
158
|
+
def on_termux() -> bool:
|
|
159
|
+
"""Detect if running in Termux environment on Android, based on Termux-specific environmental variables."""
|
|
160
|
+
|
|
161
|
+
if platform.system() != 'Linux':
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
termux_path_prefix = '/data/data/com.termux'
|
|
165
|
+
|
|
166
|
+
# Termux-specific environment variable ($PREFIX)
|
|
167
|
+
# The actual prefix is /data/data/com.termux/files/usr
|
|
168
|
+
if os.environ.get('PREFIX', default='').startswith(termux_path_prefix + '/usr'):
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
# Termux-specific environment variable ($HOME)
|
|
172
|
+
# The actual home is /data/data/com.termux/files/home
|
|
173
|
+
if os.environ.get('HOME', default='').startswith(termux_path_prefix + '/home'):
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
# Code insight: The os.environ.get command returns the supplied default if the key is not found.
|
|
177
|
+
# None is retured if a default is not speficied.
|
|
178
|
+
|
|
179
|
+
# Termux-specific environment variable ($TERMUX_VERSION)
|
|
180
|
+
if 'TERMUX_VERSION' in os.environ:
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
def on_freebsd() -> bool:
|
|
186
|
+
"""Detect if running on FreeBSD."""
|
|
187
|
+
return platform.system() == 'FreeBSD'
|
|
188
|
+
|
|
189
|
+
def on_linux():
|
|
190
|
+
"""Detect if running on Linux."""
|
|
191
|
+
return platform.system() == 'Linux'
|
|
192
|
+
|
|
193
|
+
def on_android() -> bool:
|
|
194
|
+
"""
|
|
195
|
+
Detect if running on Android.
|
|
196
|
+
|
|
197
|
+
Note: The on_termux() function is more robust and safe for Termux.
|
|
198
|
+
Checking for Termux with on_termux() does not require checking for Android with on_android().
|
|
199
|
+
|
|
200
|
+
on_android() will be True on:
|
|
201
|
+
- Sandboxed IDE's:
|
|
202
|
+
- Pydroid3
|
|
203
|
+
- QPython
|
|
204
|
+
- `proot`-reliant user-space containers:
|
|
205
|
+
- Termux
|
|
206
|
+
- Andronix
|
|
207
|
+
- UserLand
|
|
208
|
+
- AnLinux
|
|
209
|
+
|
|
210
|
+
on_android() will be False on:
|
|
211
|
+
- Full Virtual Machines:
|
|
212
|
+
- VirtualBox
|
|
213
|
+
- VMware
|
|
214
|
+
- QEMU
|
|
215
|
+
"""
|
|
216
|
+
# Explicitly check for Linux kernel name first
|
|
217
|
+
if platform.system() != 'Linux':
|
|
218
|
+
return False
|
|
219
|
+
return "android" in platform.platform().lower()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def on_wsl():
|
|
223
|
+
"""Return True if running inside Windows Subsystem for Linux (WSL or WSL2)."""
|
|
224
|
+
# Must look like Linux, not Windows
|
|
225
|
+
if platform.system() != "Linux":
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- Check environment variables for WSL2 ---
|
|
230
|
+
# False negative risk:
|
|
231
|
+
# Environment variables may be absent in older WSL1 installs.
|
|
232
|
+
# False negative likelihood: low.
|
|
233
|
+
if "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ:
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
# --- Check kernel info for 'microsoft' or 'wsl' string (Fallback) ---
|
|
237
|
+
# False negative risk:
|
|
238
|
+
# Custom kernels, future Windows versions, or minimal WSL distros may omit 'microsoft' in strings.
|
|
239
|
+
# False negative likelihood: Very low to moderate.
|
|
240
|
+
try:
|
|
241
|
+
with open("/proc/version") as f:
|
|
242
|
+
version_info = f.read().lower()
|
|
243
|
+
if "microsoft" in version_info or "wsl" in version_info:
|
|
244
|
+
return True
|
|
245
|
+
except (IOError, OSError):
|
|
246
|
+
# This block would catch the PermissionError!
|
|
247
|
+
# It would simply 'pass' and move on.
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# Check for WSL-specific mounts (fallback)
|
|
252
|
+
"""
|
|
253
|
+
/proc/sys/kernel/osrelease
|
|
254
|
+
Purpose: Contains the kernel release string. In WSL, it usually contains "microsoft" (WSL2) or "microsoft-standard" (WSL1).
|
|
255
|
+
Very reliable for detecting WSL1 and WSL2 unless someone compiled a custom kernel and removed the microsoft string.
|
|
256
|
+
|
|
257
|
+
False negative risk:
|
|
258
|
+
If /proc/sys/kernel/osrelease cannot be read due to permissions, a containerized WSL distro, or some sandboxed environment.
|
|
259
|
+
# False negative likelihood: Very low.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
with open("/proc/sys/kernel/osrelease") as f:
|
|
263
|
+
osrelease = f.read().lower()
|
|
264
|
+
if "microsoft" in osrelease:
|
|
265
|
+
return True
|
|
266
|
+
except (IOError, OSError):
|
|
267
|
+
# This block would catch the PermissionError, an FileNotFound
|
|
268
|
+
pass
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def on_pydroid():
|
|
272
|
+
"""Return True if running under Pydroid 3 (Android app)."""
|
|
273
|
+
if not on_android():
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
exe = (sys.executable or "").lower()
|
|
277
|
+
if "pydroid" in exe or "ru.iiec.pydroid3" in exe:
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
return any("pydroid" in p.lower() for p in sys.path)
|
|
281
|
+
|
|
282
|
+
def on_windows() -> bool:
|
|
283
|
+
"""Detect if running on Windows."""
|
|
284
|
+
return platform.system() == 'Windows'
|
|
285
|
+
|
|
286
|
+
def on_apple() -> bool:
|
|
287
|
+
"""Detect if running on Apple."""
|
|
288
|
+
return platform.system() == 'Darwin'
|
|
289
|
+
|
|
290
|
+
def on_ish_alpine() -> bool:
|
|
291
|
+
"""Detect if running in iSH Alpine environment on iOS."""
|
|
292
|
+
# platform.system() usually returns 'Linux' in iSH
|
|
293
|
+
|
|
294
|
+
# iSH runs on iOS but reports 'Linux' via platform.system()
|
|
295
|
+
if platform.system() != 'Linux':
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
# On iSH, /etc/apk/ will exist. However, this is not unique to iSH as standard Alpine Linux also has this directory.
|
|
299
|
+
# Therefore, we need an additional check to differentiate iSH from standard Alpine.
|
|
300
|
+
# HIGHLY SPECIFIC iSH CHECK: Look for the unique /proc/ish/ directory.
|
|
301
|
+
# This directory is created by the iSH pseudo-kernel and does not exist
|
|
302
|
+
# on standard Alpine or other Linux distributions.
|
|
303
|
+
if os.path.isdir('/etc/apk/') and os.path.isdir('/proc/ish'):
|
|
304
|
+
# This combination is highly specific to iSH Alpine.
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
def in_repl() -> bool:
|
|
310
|
+
"""
|
|
311
|
+
Detects if the code is running in the Python interactive REPL (e.g., when 'python' is typed in a console).
|
|
312
|
+
|
|
313
|
+
This function specifically checks for the Python REPL by verifying the presence of the interactive
|
|
314
|
+
prompt (`sys.ps1`). It returns False for other interactive terminal scenarios, such as running a
|
|
315
|
+
PyInstaller binary in a console.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
bool: True if running in the Python REPL; False otherwise.
|
|
319
|
+
"""
|
|
320
|
+
return hasattr(sys, 'ps1')
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --- BUILD AND EXECUTABLE CHECKS ---
|
|
324
|
+
|
|
325
|
+
def as_pyinstaller():
|
|
326
|
+
"""Detects if the Python script is running as a 'frozen' in the course of generating a PyInstaller binary executable."""
|
|
327
|
+
# If the app is frozen AND has the PyInstaller-specific temporary folder path
|
|
328
|
+
return as_frozen() and hasattr(sys, '_MEIPASS')
|
|
329
|
+
|
|
330
|
+
# The standard way to check for a frozen state:
|
|
331
|
+
def as_frozen():
|
|
332
|
+
"""
|
|
333
|
+
Detects if the Python script is running as a 'frozen' (standalone)
|
|
334
|
+
executable created by a tool like PyInstaller, cx_Freeze, or Nuitka.
|
|
335
|
+
|
|
336
|
+
This check is crucial for handling file paths, finding resources,
|
|
337
|
+
and general environment assumptions, as a frozen executable's
|
|
338
|
+
structure differs significantly from a standard script execution
|
|
339
|
+
or a virtual environment.
|
|
340
|
+
|
|
341
|
+
The check is based on examining the 'frozen' attribute of the sys module.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
bool: True if the application is running as a frozen executable;
|
|
345
|
+
False otherwise.
|
|
346
|
+
"""
|
|
347
|
+
return getattr(sys, 'frozen', False)
|
|
348
|
+
|
|
349
|
+
# --- Binary Characteristic Checks ---
|
|
350
|
+
def is_elf(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
|
|
351
|
+
"""Checks if the currently running executable (sys.argv[0]) is a standalone PyInstaller-built ELF binary."""
|
|
352
|
+
# If it's a pipx installation, it is not the monolithic binary we are concerned with here.
|
|
353
|
+
exec_path, is_valid = _check_executable_path(exec_path, debug and not suppress_debug)
|
|
354
|
+
if not is_valid:
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
# Check the magic number: The first four bytes of an ELF file are 0x7f, 'E', 'L', 'F' (b'\x7fELF').
|
|
359
|
+
# This is the most reliable way to determine if the executable is a native binary wrapper (like PyInstaller's).
|
|
360
|
+
magic_bytes = read_magic_bytes(exec_path, 4, debug and not suppress_debug)
|
|
361
|
+
if magic_bytes is None:
|
|
362
|
+
return False
|
|
363
|
+
return magic_bytes == b'\x7fELF'
|
|
364
|
+
except (OSError, IOError) as e:
|
|
365
|
+
if debug:
|
|
366
|
+
logging.debug("False (Exception during file check)")
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
def is_pyz(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
|
|
370
|
+
"""Checks if the currently running executable (sys.argv[0]) is a PYZ zipapp ."""
|
|
371
|
+
|
|
372
|
+
# If it's a pipx installation, it is not the monolithic binary we are concerned with here.
|
|
373
|
+
exec_path, is_valid = _check_executable_path(exec_path, debug and not suppress_debug)
|
|
374
|
+
if not is_valid:
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
# Check if the extension is PYZ
|
|
378
|
+
if not str(exec_path).endswith(".pyz"):
|
|
379
|
+
if debug:
|
|
380
|
+
logging.debug("is_pyz()=False (Not a .pyz file)")
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
if not _check_if_zip(exec_path):
|
|
384
|
+
if debug:
|
|
385
|
+
logging.debug("False (Not a valid ZIP file)")
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def is_windows_portable_executable(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
|
|
392
|
+
"""
|
|
393
|
+
Checks if the specified path or sys.argv[0] is a Windows Portable Executable (PE) binary.
|
|
394
|
+
Windows Portable Executables include .exe, .dll, and other binaries.
|
|
395
|
+
The standard way to check for a PE is to look for the MZ magic number at the very beginning of the file.
|
|
396
|
+
"""
|
|
397
|
+
exec_path, is_valid = _check_executable_path(exec_path, debug and not suppress_debug)
|
|
398
|
+
if not is_valid:
|
|
399
|
+
return False
|
|
400
|
+
try:
|
|
401
|
+
magic_bytes = read_magic_bytes(exec_path, 2, debug and not suppress_debug)
|
|
402
|
+
if magic_bytes is None:
|
|
403
|
+
return False
|
|
404
|
+
result = magic_bytes.startswith(b"MZ")
|
|
405
|
+
return result
|
|
406
|
+
except (OSError, IOError) as e:
|
|
407
|
+
if debug:
|
|
408
|
+
logging.debug(f"is_windows_portable_executable() = False (Exception: {e})")
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
def is_macos_executable(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
|
|
412
|
+
"""
|
|
413
|
+
Checks if the currently running executable is a macOS/Darwin Mach-O binary,
|
|
414
|
+
and explicitly excludes pipx-managed environments.
|
|
415
|
+
"""
|
|
416
|
+
exec_path, is_valid = _check_executable_path(exec_path, debug and not suppress_debug)
|
|
417
|
+
if not is_valid:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
# Check the magic number: Mach-O binaries start with specific 4-byte headers.
|
|
422
|
+
# Common ones are: b'\xfe\xed\xfa\xce' (32-bit) or b'\xfe\xed\xfa\xcf' (64-bit)
|
|
423
|
+
|
|
424
|
+
magic_bytes = read_magic_bytes(exec_path, 4, debug and not suppress_debug)
|
|
425
|
+
if magic_bytes is None:
|
|
426
|
+
return False
|
|
427
|
+
# Common Mach-O magic numbers (including their reversed-byte counterparts)
|
|
428
|
+
MACHO_MAGIC = {
|
|
429
|
+
b'\xfe\xed\xfa\xce', # MH_MAGIC
|
|
430
|
+
b'\xce\xfa\xed\xfe', # MH_CIGAM (byte-swapped)
|
|
431
|
+
b'\xfe\xed\xfa\xcf', # MH_MAGIC_64
|
|
432
|
+
b'\xcf\xfa\xed\xfe', # MH_CIGAM_64 (byte-swapped)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
is_macho = magic_bytes in MACHO_MAGIC
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
return is_macho
|
|
439
|
+
|
|
440
|
+
except (OSError, IOError) as e:
|
|
441
|
+
if debug:
|
|
442
|
+
logging.debug("is_macos_executable() = False (Exception during file check)")
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def is_pipx(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool = True) -> bool:
|
|
447
|
+
"""Checks if the executable is running from a pipx managed environment."""
|
|
448
|
+
exec_path, is_valid = _check_executable_path(exec_path, debug and not suppress_debug, check_pipx=False)
|
|
449
|
+
# check_pipx arg should be false when calling from inside of is_pipx() to avoid recursion error
|
|
450
|
+
# For safety, _check_executable_path() guards against this.
|
|
451
|
+
if not is_valid:
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
interpreter_path = Path(sys.executable).resolve()
|
|
456
|
+
pipx_bin_path, pipx_venv_base_path = _get_pipx_paths()
|
|
457
|
+
|
|
458
|
+
# Normalize paths for comparison
|
|
459
|
+
norm_exec_path = str(exec_path).lower()
|
|
460
|
+
norm_interp_path = str(interpreter_path).lower()
|
|
461
|
+
pipx_venv_base_str = str(pipx_venv_base_path).lower()
|
|
462
|
+
|
|
463
|
+
if debug:
|
|
464
|
+
logging.debug(f"EXEC_PATH: {exec_path}")
|
|
465
|
+
logging.debug(f"INTERP_PATH: {interpreter_path}")
|
|
466
|
+
logging.debug(f"PIPX_BIN_PATH: {pipx_bin_path}")
|
|
467
|
+
logging.debug(f"PIPX_VENV_BASE: {pipx_venv_base_path}")
|
|
468
|
+
is_in_pipx_venv_base = norm_interp_path.startswith(pipx_venv_base_str)
|
|
469
|
+
logging.debug(f"Interpreter path resides somewhere within the pipx venv base hierarchy: {is_in_pipx_venv_base}")
|
|
470
|
+
logging.debug(
|
|
471
|
+
f"This determines whether the current interpreter is managed by pipx: {is_in_pipx_venv_base}"
|
|
472
|
+
)
|
|
473
|
+
if "pipx/venvs" in norm_exec_path or "pipx/venvs" in norm_interp_path:
|
|
474
|
+
if debug:
|
|
475
|
+
logging.debug("is_pipx() is True // Signature Check")
|
|
476
|
+
return True
|
|
477
|
+
|
|
478
|
+
if norm_interp_path.startswith(pipx_venv_base_str):
|
|
479
|
+
if debug:
|
|
480
|
+
logging.debug("is_pipx() is True // Interpreter Base Check")
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
if norm_exec_path.startswith(pipx_venv_base_str):
|
|
484
|
+
if debug:
|
|
485
|
+
logging.debug("is_pipx() is True // Executable Base Check")
|
|
486
|
+
return True
|
|
487
|
+
|
|
488
|
+
if debug:
|
|
489
|
+
logging.debug("is_pipx() is False")
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
except Exception:
|
|
493
|
+
if debug:
|
|
494
|
+
logging.debug("False (Exception during pipx check)")
|
|
495
|
+
|
|
496
|
+
def is_python_script(path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
|
|
497
|
+
"""
|
|
498
|
+
Checks if the specified path or running script is a Python source file (.py).
|
|
499
|
+
|
|
500
|
+
By default, checks the running script (`sys.argv[0]`). If a specific `path` is
|
|
501
|
+
provided, checks that path instead. Uses `Path.resolve()` for stable path handling.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
path: Optional; path to the file to check (str or Path). If None, defaults to `sys.argv[0]`.
|
|
505
|
+
debug: If True, prints the path being checked.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
bool: True if the specified or default path is a Python source file (.py); False otherwise.
|
|
509
|
+
"""
|
|
510
|
+
exec_path, is_valid = _check_executable_path(path, debug and not suppress_debug, check_pipx=False)
|
|
511
|
+
if not is_valid:
|
|
512
|
+
return False
|
|
513
|
+
return exec_path.suffix.lower() == '.py'
|
|
514
|
+
|
|
515
|
+
# --- Interpreter Check ---
|
|
516
|
+
|
|
517
|
+
def interp_path(debug: bool = False) -> str:
|
|
518
|
+
"""
|
|
519
|
+
Returns the path to the Python interpreter binary and optionally prints it.
|
|
520
|
+
|
|
521
|
+
This function wraps `sys.executable` to provide the path to the interpreter
|
|
522
|
+
(e.g., '/data/data/com.termux/files/usr/bin/python3' in Termux or the embedded
|
|
523
|
+
interpreter in a frozen executable). If the path is empty (e.g., in some embedded
|
|
524
|
+
or sandboxed environments), an empty string is returned.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
print_path: If True, prints the interpreter path to stdout.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
str: The path to the Python interpreter binary, or an empty string if unavailable.
|
|
531
|
+
"""
|
|
532
|
+
path = sys.executable
|
|
533
|
+
if debug:
|
|
534
|
+
logging.debug(f"Python interpreter path: {path}")
|
|
535
|
+
return path
|
|
536
|
+
|
|
537
|
+
# --- TTY Check ---
|
|
538
|
+
def interactive_terminal_is_available():
|
|
539
|
+
"""
|
|
540
|
+
Check if the script is running in an interactive terminal.
|
|
541
|
+
Assumpton:
|
|
542
|
+
If interactive_terminal_is_available() returns True,
|
|
543
|
+
then typer.prompt() or input() will work reliably,
|
|
544
|
+
without getting lost in a log or lost entirely.
|
|
545
|
+
|
|
546
|
+
"""
|
|
547
|
+
# Address walmart demo unit edge case, fast check, though this might hamstring othwrwise successful processes
|
|
548
|
+
if in_repl() and user_darrin_deyoung():
|
|
549
|
+
return False
|
|
550
|
+
# A new shell can be launched to print stuff
|
|
551
|
+
if not can_spawn_shell():
|
|
552
|
+
return False
|
|
553
|
+
# A user can interact with a console, providing input
|
|
554
|
+
if not can_read_input():
|
|
555
|
+
return False
|
|
556
|
+
# Check if a tty is attached to stdin
|
|
557
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
558
|
+
|
|
559
|
+
def user_darrin_deyoung():
|
|
560
|
+
"""Common demo unit undicator, edge case that is unable to launch terminal"""
|
|
561
|
+
if not on_windows():
|
|
562
|
+
return False
|
|
563
|
+
username = getpass.getuser()
|
|
564
|
+
return username.lower() == "darrin deyoung"
|
|
565
|
+
|
|
566
|
+
def can_spawn_shell(override_known:bool=False)->bool:
|
|
567
|
+
"""Check if a shell command can be executed successfully."""
|
|
568
|
+
global _CAN_SPAWN_SHELL
|
|
569
|
+
if _CAN_SPAWN_SHELL is not None and override_known is False:
|
|
570
|
+
return _CAN_SPAWN_SHELL
|
|
571
|
+
try:
|
|
572
|
+
result = subprocess.run( ['echo', 'hello'],
|
|
573
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
574
|
+
timeout=2 )
|
|
575
|
+
_CAN_SPAWN_SHELL = True
|
|
576
|
+
return result.returncode == 0
|
|
577
|
+
except subprocess.TimeoutExpired:
|
|
578
|
+
logging.debug("Shell spawn failed: TimeoutExpired")
|
|
579
|
+
_CAN_SPAWN_SHELL = False
|
|
580
|
+
return False
|
|
581
|
+
except subprocess.SubprocessError:
|
|
582
|
+
logging.debug("Shell spawn failed: SubprocessError")
|
|
583
|
+
_CAN_SPAWN_SHELL = False
|
|
584
|
+
return False
|
|
585
|
+
except OSError:
|
|
586
|
+
_CAN_SPAWN_SHELL = False
|
|
587
|
+
logging.debug("Shell spawn failed: OSError (likely permission or missing binary)")
|
|
588
|
+
return False
|
|
589
|
+
|
|
590
|
+
def can_read_input(override_known:bool=False)-> bool:
|
|
591
|
+
"""Check if input is readable from stdin."""
|
|
592
|
+
global _CAN_READ_INPUT
|
|
593
|
+
if _CAN_READ_INPUT is not None and override_known is False:
|
|
594
|
+
return _CAN_READ_INPUT
|
|
595
|
+
try:
|
|
596
|
+
_CAN_READ_INPUT = select.select([sys.stdin], [], [], 0.1)[0]
|
|
597
|
+
return _CAN_READ_INPUT
|
|
598
|
+
except ValueError:
|
|
599
|
+
logging.debug("Input check failed: ValueError (invalid file descriptor)")
|
|
600
|
+
_CAN_READ_INPUT = False
|
|
601
|
+
return False
|
|
602
|
+
except OSError:
|
|
603
|
+
logging.debug("Input check failed: OSError (likely I/O issue)")
|
|
604
|
+
_CAN_READ_INPUT = False
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
# --- Browser Check ---
|
|
608
|
+
def web_browser_is_available() -> bool:
|
|
609
|
+
""" Check if a web browser can be launched in the current environment."""
|
|
610
|
+
try:
|
|
611
|
+
# 1. Standard Python check
|
|
612
|
+
webbrowser.get()
|
|
613
|
+
return True
|
|
614
|
+
except webbrowser.Error:
|
|
615
|
+
# Fallback needed. Check for external launchers.
|
|
616
|
+
# 2. Termux specific check
|
|
617
|
+
if on_termux() and shutil.which("termux-open-url"):
|
|
618
|
+
return True
|
|
619
|
+
# 3. General Linux check
|
|
620
|
+
if shutil.which("xdg-open"):
|
|
621
|
+
return True
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
# --- LAUNCH MECHANISMS BASED ON ENVIRONMENT ---
|
|
625
|
+
def edit_textfile(path: Path | str | None = None) -> None:
|
|
626
|
+
#def open_text_file_for_editing(path): # defunct function name as of 1.0.16
|
|
627
|
+
"""
|
|
628
|
+
Opens a file with the environment's default application (Windows, Linux, macOS)
|
|
629
|
+
or a guaranteed console editor (nano) in constrained environments (Termux, iSH).
|
|
630
|
+
Ensures line-ending compatibility where possible.
|
|
631
|
+
|
|
632
|
+
This function is known to fail on PyDroid3, where on_linus() is True but xdg-open
|
|
633
|
+
is not available.
|
|
634
|
+
"""
|
|
635
|
+
if path is None:
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
path = Path(path).resolve()
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
if on_windows():
|
|
642
|
+
os.startfile(path)
|
|
643
|
+
elif on_termux():
|
|
644
|
+
# Install dependencies if missing (Termux pkg returns non-zero if already installed, so no check=True)
|
|
645
|
+
subprocess.run(['pkg','install', 'dos2unix', 'nano'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
646
|
+
_run_dos2unix(path)
|
|
647
|
+
subprocess.run(['nano', str(path)])
|
|
648
|
+
elif on_ish_alpine():
|
|
649
|
+
# Install dependencies if missing (apk returns 0 if already installed, so check=True is safe)
|
|
650
|
+
subprocess.run(['apk','add', 'dos2unix'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
651
|
+
subprocess.run(['apk','add', 'nano'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
652
|
+
_run_dos2unix(path)
|
|
653
|
+
subprocess.run(['nano', str(path)])
|
|
654
|
+
# --- Standard Unix-like Systems (Conversion + Default App) ---
|
|
655
|
+
elif on_linux():
|
|
656
|
+
_run_dos2unix(path) # Safety conversion for user-defined console apps
|
|
657
|
+
subprocess.run(['xdg-open', str(path)])
|
|
658
|
+
elif on_apple():
|
|
659
|
+
_run_dos2unix(path) # Safety conversion for user-defined console apps
|
|
660
|
+
subprocess.run(['open', str(path)])
|
|
661
|
+
else:
|
|
662
|
+
print("Unsupported operating system.")
|
|
663
|
+
except Exception as e:
|
|
664
|
+
print("The file could not be opened for editing in the current environment: {e}")
|
|
665
|
+
"""
|
|
666
|
+
Why Not Use check=True on Termux:
|
|
667
|
+
The pkg utility in Termux is a wrapper around Debian's apt. When you run pkg install <package>, if the package is already installed, the utility often returns an exit code of 100 (or another non-zero value) to indicate that no changes were made because the package was already present.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
# --- Helper Functions ---
|
|
671
|
+
def _run_dos2unix(path: Path | str | None = None):
|
|
672
|
+
"""Attempt to run dos2unix, failing silently if not installed."""
|
|
673
|
+
|
|
674
|
+
path = Path(path).resolve()
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
# We rely on shutil.which not being needed, as this is a robust built-in utility on most targets
|
|
678
|
+
# The command won't raise an exception unless the process itself fails, not just if the utility isn't found.
|
|
679
|
+
# We also don't use check=True here to allow silent failure if the utility is missing (e.g., minimalist Linux).
|
|
680
|
+
subprocess.run(['dos2unix', path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
681
|
+
except FileNotFoundError:
|
|
682
|
+
# This will be raised if 'dos2unix' is not on the system PATH
|
|
683
|
+
pass
|
|
684
|
+
except Exception:
|
|
685
|
+
# Catch other subprocess errors (e.g. permission issues)
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
def read_magic_bytes(path: str, length: int = 4, debug: bool = False) -> bytes | None:
|
|
689
|
+
"""Return the first few bytes of a file for type detection.
|
|
690
|
+
Returns None if the file cannot be read or does not exist.
|
|
691
|
+
"""
|
|
692
|
+
try:
|
|
693
|
+
with open(path, "rb") as f:
|
|
694
|
+
magic = f.read(length)
|
|
695
|
+
if debug:
|
|
696
|
+
logging.debug(f"Magic bytes: {magic!r}")
|
|
697
|
+
return magic
|
|
698
|
+
except Exception as e:
|
|
699
|
+
if debug:
|
|
700
|
+
logging.debug(f"False (Error during file check: {e})")
|
|
701
|
+
#return False # not typesafe
|
|
702
|
+
#return b'' # could be misunderstood as what was found
|
|
703
|
+
return None # no way to conflate that this was a legitimate error
|
|
704
|
+
|
|
705
|
+
def _get_pipx_paths():
|
|
706
|
+
"""
|
|
707
|
+
Returns the configured/default pipx binary and home directories.
|
|
708
|
+
Assumes you indeed have a pipx dir.
|
|
709
|
+
"""
|
|
710
|
+
# 1. PIPX_BIN_DIR (where the symlinks live, e.g., ~/.local/bin)
|
|
711
|
+
pipx_bin_dir_str = os.environ.get('PIPX_BIN_DIR')
|
|
712
|
+
if pipx_bin_dir_str:
|
|
713
|
+
pipx_bin_path = Path(pipx_bin_dir_str).resolve()
|
|
714
|
+
else:
|
|
715
|
+
# Default binary path (common across platforms for user installs)
|
|
716
|
+
pipx_bin_path = Path.home() / '.local' / 'bin'
|
|
717
|
+
|
|
718
|
+
# 2. PIPX_HOME (where the isolated venvs live, e.g., ~/.local/pipx/venvs)
|
|
719
|
+
pipx_home_str = os.environ.get('PIPX_HOME')
|
|
720
|
+
if pipx_home_str:
|
|
721
|
+
# PIPX_HOME is the base, venvs are in PIPX_HOME/venvs
|
|
722
|
+
pipx_venv_base = Path(pipx_home_str).resolve() / 'venvs'
|
|
723
|
+
else:
|
|
724
|
+
# Fallback to the modern default for PIPX_HOME (XDG standard)
|
|
725
|
+
# Note: pipx is smart and may check the older ~/.local/pipx too
|
|
726
|
+
# but the XDG one is the current standard.
|
|
727
|
+
pipx_venv_base = Path.home() / '.local' / 'share' / 'pipx' / 'venvs'
|
|
728
|
+
|
|
729
|
+
return pipx_bin_path, pipx_venv_base.resolve()
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _check_if_zip(path: Path | str | None) -> bool:
|
|
733
|
+
"""Checks if the file at the given path is a valid ZIP archive."""
|
|
734
|
+
if path is None:
|
|
735
|
+
return False
|
|
736
|
+
path = Path(path).resolve()
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
return zipfile.is_zipfile(path)
|
|
740
|
+
except Exception:
|
|
741
|
+
# Handle cases where the path might be invalid, or other unexpected errors
|
|
742
|
+
return False
|
|
743
|
+
|
|
744
|
+
def _check_executable_path(exec_path: Path | str | None,
|
|
745
|
+
debug: bool = False,
|
|
746
|
+
check_pipx: bool = True
|
|
747
|
+
) -> tuple[Path | None, bool]: #compensate with __future__, may cause type checker issues
|
|
748
|
+
"""
|
|
749
|
+
Helper function to resolve an executable path and perform common checks.
|
|
750
|
+
|
|
751
|
+
Returns:
|
|
752
|
+
tuple[Path | None, bool]: (Resolved path, is_valid)
|
|
753
|
+
- Path: The resolved Path object, or None if invalid
|
|
754
|
+
- bool: Whether the path should be considered valid for subsequent checks
|
|
755
|
+
"""
|
|
756
|
+
# 1. Determine path
|
|
757
|
+
if exec_path is None:
|
|
758
|
+
exec_path = Path(sys.argv[0]).resolve() if sys.argv[0] and sys.argv[0] != '-c' else None
|
|
759
|
+
else:
|
|
760
|
+
exec_path = Path(exec_path).resolve()
|
|
761
|
+
|
|
762
|
+
if debug:
|
|
763
|
+
logging.debug(f"Checking executable path: {exec_path}")
|
|
764
|
+
|
|
765
|
+
# 2. Handle missing path
|
|
766
|
+
if exec_path is None:
|
|
767
|
+
if debug:
|
|
768
|
+
logging.debug("_check_executable_path() returns (None, False) // exec_path is None")
|
|
769
|
+
return None, False
|
|
770
|
+
|
|
771
|
+
# 3. Ensure path actually exists and is a file
|
|
772
|
+
if not exec_path.is_file():
|
|
773
|
+
if debug:
|
|
774
|
+
logging.debug("_check_executable_path() returns (exec_path, False) // exec_path is not a file")
|
|
775
|
+
return exec_path, False
|
|
776
|
+
|
|
777
|
+
# 4. Avoid recursive pipx check loops
|
|
778
|
+
# This guard ensures we don’t recursively call _check_executable_path()
|
|
779
|
+
# via is_pipx() -> _check_executable_path() -> is_pipx() -> ...
|
|
780
|
+
if check_pipx:
|
|
781
|
+
caller = sys._getframe(1).f_code.co_name
|
|
782
|
+
if caller != "is_pipx":
|
|
783
|
+
if is_pipx(exec_path, debug):
|
|
784
|
+
if debug:
|
|
785
|
+
logging.debug("_check_executable_path() returns (exec_path, False) // is_pipx(exec_path) is True")
|
|
786
|
+
return exec_path, False
|
|
787
|
+
|
|
788
|
+
return exec_path, True
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# --- Main Function for report and CLI compatibility ---
|
|
792
|
+
|
|
793
|
+
def main(path=None, debug=False):
|
|
794
|
+
"""Print a comprehensive environment report.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
path (Path | str | None): Path to inspect (defaults to sys.argv[0]).
|
|
798
|
+
debug (bool): Enable verbose debug output.
|
|
799
|
+
"""
|
|
800
|
+
if debug:
|
|
801
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
802
|
+
logging.getLogger('matplotlib').setLevel(logging.WARNING) # Suppress matplotlib debug logs
|
|
803
|
+
print("================================")
|
|
804
|
+
print("======= PyHabitat Report =======")
|
|
805
|
+
print("================================")
|
|
806
|
+
print("\nCurrent Build Checks ")
|
|
807
|
+
print("# // Based on hasattr(sys,..) and getattr(sys,..)")
|
|
808
|
+
print("------------------------------")
|
|
809
|
+
print(f"in_repl(): {in_repl()}")
|
|
810
|
+
print(f"as_frozen(): {as_frozen()}")
|
|
811
|
+
print(f"as_pyinstaller(): {as_pyinstaller()}")
|
|
812
|
+
print("\nOperating System Checks")
|
|
813
|
+
print("# // Based on platform.system()")
|
|
814
|
+
print("------------------------------")
|
|
815
|
+
print(f"on_windows(): {on_windows()}")
|
|
816
|
+
print(f"on_apple(): {on_apple()}")
|
|
817
|
+
print(f"on_linux(): {on_linux()}")
|
|
818
|
+
print(f"on_wsl(): {on_wsl()}")
|
|
819
|
+
print(f"on_android(): {on_android()}")
|
|
820
|
+
print(f"on_termux(): {on_termux()}")
|
|
821
|
+
print(f"on_pydroid(): {on_pydroid()}")
|
|
822
|
+
print(f"on_ish_alpine(): {on_ish_alpine()}")
|
|
823
|
+
print(f"on_freebsd(): {on_freebsd()}")
|
|
824
|
+
print("\nCapability Checks")
|
|
825
|
+
print("-------------------------")
|
|
826
|
+
print(f"tkinter_is_available(): {tkinter_is_available()}")
|
|
827
|
+
print(f"matplotlib_is_available_for_gui_plotting(): {matplotlib_is_available_for_gui_plotting()}")
|
|
828
|
+
print(f"matplotlib_is_available_for_headless_image_export(): {matplotlib_is_available_for_headless_image_export()}")
|
|
829
|
+
print(f"web_browser_is_available(): {web_browser_is_available()}")
|
|
830
|
+
print(f"interactive_terminal_is_available(): {interactive_terminal_is_available()}")
|
|
831
|
+
print("\nInterpreter Checks")
|
|
832
|
+
print("# // Based on sys.executable()")
|
|
833
|
+
print("-----------------------------")
|
|
834
|
+
print(f"interp_path(): {interp_path()}")
|
|
835
|
+
if debug:
|
|
836
|
+
# Do these debug prints once to avoid redundant prints
|
|
837
|
+
# Supress redundant prints explicity using suppress_debug=True,
|
|
838
|
+
# so that only unique information gets printed for each check,
|
|
839
|
+
# even when more than one use the same functions which include debugging logs.
|
|
840
|
+
#print(f"_check_executable_path(interp_path(), debug=True)")
|
|
841
|
+
_check_executable_path(interp_path(), debug=debug)
|
|
842
|
+
#print(f"read_magic_bites(interp_path(), debug=True)")
|
|
843
|
+
read_magic_bytes(interp_path(), debug=debug)
|
|
844
|
+
print(f"is_elf(interp_path()): {is_elf(interp_path(), debug=debug, suppress_debug=True)}")
|
|
845
|
+
print(f"is_windows_portable_executable(interp_path()): {is_windows_portable_executable(interp_path(), debug=debug, suppress_debug=True)}")
|
|
846
|
+
print(f"is_macos_executable(interp_path()): {is_macos_executable(interp_path(), debug=debug, suppress_debug=True)}")
|
|
847
|
+
print(f"is_pyz(interp_path()): {is_pyz(interp_path(), debug=debug, suppress_debug=True)}")
|
|
848
|
+
print(f"is_pipx(interp_path()): {is_pipx(interp_path(), debug=debug, suppress_debug=True)}")
|
|
849
|
+
print(f"is_python_script(interp_path()): {is_python_script(interp_path(), debug=debug, suppress_debug=True)}")
|
|
850
|
+
print("\nCurrent Environment Check")
|
|
851
|
+
print("# // Based on sys.argv[0]")
|
|
852
|
+
print("-----------------------------")
|
|
853
|
+
inspect_path = path if path is not None else (None if sys.argv[0] == '-c' else sys.argv[0])
|
|
854
|
+
logging.debug(f"Inspecting path: {inspect_path}")
|
|
855
|
+
# Early validation of path
|
|
856
|
+
if path is not None:
|
|
857
|
+
path_obj = Path(path)
|
|
858
|
+
if not path_obj.is_file():
|
|
859
|
+
print(f"Error: '{path}' is not a valid file or does not exist.")
|
|
860
|
+
if debug:
|
|
861
|
+
logging.error(f"Invalid path: '{path}' is not a file or does not exist.")
|
|
862
|
+
raise SystemExit(1)
|
|
863
|
+
script_path = None
|
|
864
|
+
if path or (sys.argv[0] and sys.argv[0] != '-c'):
|
|
865
|
+
script_path = Path(path or sys.argv[0]).resolve()
|
|
866
|
+
print(f"sys.argv[0] = {str(sys.argv[0])}")
|
|
867
|
+
if script_path is not None:
|
|
868
|
+
print(f"script_path = {script_path}")
|
|
869
|
+
if debug:
|
|
870
|
+
# Do these debug prints once to avoid redundant prints
|
|
871
|
+
# Supress redundant prints explicity using suppress_debug=True,
|
|
872
|
+
# so that only unique information gets printed for each check,
|
|
873
|
+
# even when more than one use the same functions which include debugging logs.
|
|
874
|
+
#print(f"_check_executable_path(script_path, debug=True)")
|
|
875
|
+
_check_executable_path(script_path, debug=debug)
|
|
876
|
+
#print(f"read_magic_bites(script_path, debug=True)")
|
|
877
|
+
read_magic_bytes(script_path, debug=debug)
|
|
878
|
+
print(f"is_elf(): {is_elf(script_path, debug=debug, suppress_debug=True)}")
|
|
879
|
+
print(f"is_windows_portable_executable(): {is_windows_portable_executable(script_path, debug=debug, suppress_debug=True)}")
|
|
880
|
+
print(f"is_macos_executable(): {is_macos_executable(script_path, debug=debug, suppress_debug=True)}")
|
|
881
|
+
print(f"is_pyz(): {is_pyz(script_path, debug=debug, suppress_debug=True)}")
|
|
882
|
+
print(f"is_pipx(): {is_pipx(script_path, debug=debug, suppress_debug=True)}")
|
|
883
|
+
print(f"is_python_script(): {is_python_script(script_path, debug=debug, suppress_debug=True)}")
|
|
884
|
+
else:
|
|
885
|
+
print("Skipping: ")
|
|
886
|
+
print(" is_elf(), ")
|
|
887
|
+
print(" is_windows_portable_executable(), ")
|
|
888
|
+
print(" is_macos_executable(), ")
|
|
889
|
+
print(" is_pyz(), ")
|
|
890
|
+
print(" is_pipx(), ")
|
|
891
|
+
print(" is_python_script(), ")
|
|
892
|
+
print("All False, script_path is None.")
|
|
893
|
+
print("")
|
|
894
|
+
print("=================================")
|
|
895
|
+
print("=== PyHabitat Report Complete ===")
|
|
896
|
+
print("=================================")
|
|
897
|
+
print("")
|
|
898
|
+
if is_pyz(): # and is_repl():
|
|
899
|
+
# Keep window open. This iteration is non rigorous.
|
|
900
|
+
# To address use pf Python launcher from Windows Store to launch downoaded .pyz, which closed quickly
|
|
901
|
+
try:
|
|
902
|
+
input("Press Return to Exit...")
|
|
903
|
+
except Exception as e:
|
|
904
|
+
logging.debug("input() failed")
|
|
905
|
+
|
|
906
|
+
if __name__ == "__main__":
|
|
907
|
+
main(debug=True)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ./utils.py
|
|
2
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
3
|
+
def get_version_defunct() -> str:
|
|
4
|
+
"""Retrieves the installed package version."""
|
|
5
|
+
try:
|
|
6
|
+
# The package name 'pyhabitat' must exactly match the name in your pyproject.toml
|
|
7
|
+
return version('pyhabitat')
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
# This occurs if the script is run directly from the source directory
|
|
10
|
+
# without being installed in editable mode, or if the package name is wrong.
|
|
11
|
+
return "Not Installed (Local Development or Incorrect Name)"
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
def get_version():
|
|
16
|
+
try:
|
|
17
|
+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
|
|
18
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
19
|
+
match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE)
|
|
20
|
+
if match:
|
|
21
|
+
return match.group(1)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return "Not Installed (Local Development or Incorrect Name)"
|
|
25
|
+
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
import
|
|
3
|
+
from .environment import main
|
|
4
|
+
from .environment import * # to enable CLI --list
|
|
5
|
+
from .utils import get_version
|
|
6
|
+
#import __init__ as pyhabitat # works if everything is in root, v1.0.28
|
|
7
|
+
import pyhabitat # refers to the folder
|
|
6
8
|
|
|
7
9
|
def run_cli():
|
|
8
10
|
"""Parse CLI arguments and run the pyhabitat environment report."""
|
|
@@ -34,6 +36,11 @@ def run_cli():
|
|
|
34
36
|
action="store_true",
|
|
35
37
|
help="List available callable functions in pyhabitat"
|
|
36
38
|
)
|
|
39
|
+
#parser.add_argument(
|
|
40
|
+
# "--verbose",
|
|
41
|
+
# action="store_true",
|
|
42
|
+
# help="List available callable functions in pyhabitat"
|
|
43
|
+
#)
|
|
37
44
|
|
|
38
45
|
parser.add_argument(
|
|
39
46
|
"command",
|
|
@@ -45,16 +52,22 @@ def run_cli():
|
|
|
45
52
|
args = parser.parse_args()
|
|
46
53
|
|
|
47
54
|
if args.list:
|
|
48
|
-
for name in
|
|
49
|
-
|
|
55
|
+
for name in pyhabitat.__all__:
|
|
56
|
+
func = getattr(pyhabitat, name, None)
|
|
57
|
+
if callable(func):
|
|
50
58
|
print(name)
|
|
59
|
+
if args.debug:
|
|
60
|
+
doc = func.__doc__ or "(no description)"
|
|
61
|
+
print(f"{name}: {doc}")
|
|
51
62
|
return
|
|
52
|
-
|
|
63
|
+
|
|
53
64
|
if args.command:
|
|
54
65
|
func = getattr(pyhabitat, args.command, None)
|
|
55
66
|
if callable(func):
|
|
56
67
|
print(func())
|
|
68
|
+
return # Exit after running the subcommand
|
|
57
69
|
else:
|
|
58
70
|
print(f"Unknown function: {args.command}")
|
|
71
|
+
return # Exit after reporting the unknown command
|
|
59
72
|
|
|
60
73
|
main(path=Path(args.path) if args.path else None, debug=args.debug)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
# ./utils.py
|
|
1
2
|
from importlib.metadata import version, PackageNotFoundError
|
|
2
|
-
def
|
|
3
|
+
def get_version_defunct() -> str:
|
|
3
4
|
"""Retrieves the installed package version."""
|
|
4
5
|
try:
|
|
5
6
|
# The package name 'pyhabitat' must exactly match the name in your pyproject.toml
|
|
@@ -7,4 +8,18 @@ def get_version() -> str:
|
|
|
7
8
|
except PackageNotFoundError:
|
|
8
9
|
# This occurs if the script is run directly from the source directory
|
|
9
10
|
# without being installed in editable mode, or if the package name is wrong.
|
|
10
|
-
return "Not Installed (Local Development or Incorrect Name)"
|
|
11
|
+
return "Not Installed (Local Development or Incorrect Name)"
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
def get_version():
|
|
16
|
+
try:
|
|
17
|
+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
|
|
18
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
19
|
+
match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE)
|
|
20
|
+
if match:
|
|
21
|
+
return match.group(1)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return "Not Installed (Local Development or Incorrect Name)"
|
|
25
|
+
|
|
@@ -2,10 +2,14 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
pyhabitat/__init__.py
|
|
5
|
-
pyhabitat/__main__.py
|
|
6
5
|
pyhabitat/cli.py
|
|
7
6
|
pyhabitat/environment.py
|
|
8
7
|
pyhabitat/utils.py
|
|
8
|
+
pyhabitat-build/__main__.py
|
|
9
|
+
pyhabitat-build/pyhabitat/__init__.py
|
|
10
|
+
pyhabitat-build/pyhabitat/cli.py
|
|
11
|
+
pyhabitat-build/pyhabitat/environment.py
|
|
12
|
+
pyhabitat-build/pyhabitat/utils.py
|
|
9
13
|
pyhabitat.egg-info/PKG-INFO
|
|
10
14
|
pyhabitat.egg-info/SOURCES.txt
|
|
11
15
|
pyhabitat.egg-info/dependency_links.txt
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
pyhabitat
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|