pyhabitat 1.1.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1080 @@
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
+ from functools import cache
20
+ from typing import Optional
21
+
22
+ # On Windows, we need the msvcrt module for non-blocking I/O
23
+ try:
24
+ import msvcrt
25
+ except ImportError:
26
+ msvcrt = None
27
+
28
+ __all__ = [
29
+ 'matplotlib_is_available_for_gui_plotting',
30
+ 'matplotlib_is_available_for_headless_image_export',
31
+ 'tkinter_is_available',
32
+ 'on_termux',
33
+ 'on_freebsd',
34
+ 'on_linux',
35
+ 'on_pydroid',
36
+ 'on_android',
37
+ 'on_windows',
38
+ 'on_wsl',
39
+ 'on_macos',
40
+ 'on_ish_alpine',
41
+ 'as_pyinstaller',
42
+ 'as_frozen',
43
+ 'is_elf',
44
+ 'is_pyz',
45
+ 'is_windows_portable_executable',
46
+ 'is_msix',
47
+ 'is_macos_executable',
48
+ 'is_pipx',
49
+ 'is_python_script',
50
+ 'interactive_terminal_is_available',
51
+ 'web_browser_is_available',
52
+ 'edit_textfile',
53
+ 'show_system_explorer',
54
+ 'in_repl',
55
+ 'interp_path',
56
+ 'main',
57
+ 'user_darrin_deyoung',
58
+ 'can_spawn_shell',
59
+ 'read_magic_bytes',
60
+ 'check_executable_path',
61
+ 'is_running_in_uvicorn',
62
+ ]
63
+
64
+ def clear_all_caches()->None:
65
+ """Clear every @cache used in pyhabitat, and call from CLI using --clear-cache"""
66
+ tkinter_is_available.cache_clear()
67
+ matplotlib_is_available_for_gui_plotting.cache_clear()
68
+ matplotlib_is_available_for_headless_image_export.cache_clear()
69
+ can_spawn_shell.cache_clear()
70
+ can_spawn_shell_lite.cache_clear()
71
+
72
+
73
+ # --- GUI CHECKS ---
74
+ @cache # alt to globals
75
+ def matplotlib_is_available_for_gui_plotting(termux_has_gui=False):
76
+ """Check if Matplotlib is available AND can use a GUI backend for a popup window."""
77
+ # 1. Termux exclusion check (assume no X11/GUI)
78
+ # Exclude Termux UNLESS the user explicitly provides termux_has_gui=True.
79
+ if on_termux() and not termux_has_gui:
80
+ return False
81
+
82
+ # 2. Tkinter check (The most definitive check for a working display environment)
83
+ # If tkinter can't open a window, Matplotlib's TkAgg backend will fail.
84
+ if not tkinter_is_available():
85
+ return False
86
+
87
+ # 3. Matplotlib + TkAgg check
88
+ try:
89
+ import matplotlib
90
+ import matplotlib.pyplot as plt
91
+ # Only switch to TkAgg is no interactive backend is already active.
92
+ # At this point, we know tkinter is *available*.
93
+ current_backend = matplotlib.get_backend().lower()
94
+ if current_backend in () or 'inline' in current_backend:
95
+ # Non-interactive, safe to switch
96
+ # 'TkAgg' is often the most reliable cross-platform test.
97
+ matplotlib.use('TkAgg', force=True)
98
+ else:
99
+ # already using QtAgg, Gtk3Agg, etc.
100
+ matplotlib.use(current_backend, force=True)
101
+
102
+ # 'TkAgg' != 'Agg'. The Agg backend is for non-gui image export.
103
+ if matplotlib.get_backend().lower() != 'tkagg':
104
+ matplotlib.use('TkAgg', force=True)
105
+
106
+ # A simple test call to ensure the backend initializes
107
+ # This final test catches any edge cases where tkinter is present but
108
+ # Matplotlib's *integration* with it is broken
109
+
110
+ plt.figure()
111
+ plt.close('all')
112
+
113
+ return True
114
+
115
+ except Exception:
116
+ # Catches Matplotlib ImportError or any runtime error from the plt.figure() call
117
+ return False
118
+
119
+ @cache
120
+ def matplotlib_is_available_for_headless_image_export():
121
+ """Check if Matplotlib is available AND can use the Agg backend for image export."""
122
+ try:
123
+ import matplotlib
124
+ import matplotlib.pyplot as plt
125
+ # The Agg backend (for PNG/JPEG export) is very basic and usually available
126
+ # if the core library is installed. We explicitly set it just in case.
127
+ # 'Agg' != 'TkAgg'. The TkAgg backend is for interactive gui image display.
128
+ matplotlib.use('Agg', force=True)
129
+
130
+ # A simple test to ensure a figure can be generated
131
+ fig = plt.figure()
132
+ # Ensure it can save to an in-memory buffer (to avoid disk access issues)
133
+ fig.savefig(io.BytesIO(), format='png')
134
+ plt.close(fig)
135
+ return True
136
+
137
+ except Exception as e:
138
+ return False
139
+ finally:
140
+ # guarantee no figures leak
141
+ try:
142
+ import matplotlib.pyplot as plt
143
+ plt.close('all')
144
+ except:
145
+ pass
146
+
147
+ @cache
148
+ def tkinter_is_available() -> bool:
149
+ """Check if tkinter is available and can successfully connect to a display."""
150
+
151
+ # Quick exit: If no DISPLAY is set on Linux/WSL, GUI is impossible
152
+ if on_linux() and not os.environ.get("DISPLAY"):
153
+ return False
154
+
155
+ try:
156
+ import tkinter as tk
157
+
158
+ # Perform the actual GUI backend test for absolute certainty.
159
+ # This only runs once per script execution.
160
+ root = tk.Tk()
161
+ root.withdraw()
162
+ root.update()
163
+ root.destroy()
164
+
165
+ return True
166
+ except Exception:
167
+ # Fails if: tkinter module is missing OR the display backend is unavailable
168
+ return False
169
+
170
+ # --- ENVIRONMENT AND OPERATING SYSTEM CHECKS ---
171
+ def on_termux() -> bool:
172
+ """Detect if running in Termux environment on Android, based on Termux-specific environmental variables."""
173
+
174
+ if platform.system() != 'Linux':
175
+ return False
176
+
177
+ termux_path_prefix = '/data/data/com.termux'
178
+
179
+ # Termux-specific environment variable ($PREFIX)
180
+ # The actual prefix is /data/data/com.termux/files/usr
181
+ if os.environ.get('PREFIX', default='').startswith(termux_path_prefix + '/usr'):
182
+ return True
183
+
184
+ # Termux-specific environment variable ($HOME)
185
+ # The actual home is /data/data/com.termux/files/home
186
+ if os.environ.get('HOME', default='').startswith(termux_path_prefix + '/home'):
187
+ return True
188
+
189
+ # Code insight: The os.environ.get command returns the supplied default if the key is not found.
190
+ # None is retured if a default is not speficied.
191
+
192
+ # Termux-specific environment variable ($TERMUX_VERSION)
193
+ if 'TERMUX_VERSION' in os.environ:
194
+ return True
195
+
196
+ return False
197
+
198
+ def on_freebsd() -> bool:
199
+ """Detect if running on FreeBSD."""
200
+ return platform.system() == 'FreeBSD'
201
+
202
+ def on_linux():
203
+ """
204
+ Detect if running on Linux.
205
+ Basic, expected; `platform.system() == 'Linux'`
206
+ """
207
+ return platform.system() == 'Linux'
208
+
209
+ def on_android() -> bool:
210
+ """
211
+ Detect if running on Android.
212
+
213
+ Note: The on_termux() function is more robust and safe for Termux.
214
+ Checking for Termux with on_termux() does not require checking for Android with on_android().
215
+
216
+ on_android() will be True on:
217
+ - Sandboxed IDE's:
218
+ - Pydroid3
219
+ - QPython
220
+ - `proot`-reliant user-space containers:
221
+ - Termux
222
+ - Andronix
223
+ - UserLand
224
+ - AnLinux
225
+
226
+ on_android() will be False on:
227
+ - Full Virtual Machines:
228
+ - VirtualBox
229
+ - VMware
230
+ - QEMU
231
+ """
232
+ # Explicitly check for Linux kernel name first
233
+ if platform.system() != 'Linux':
234
+ return False
235
+ return "android" in platform.platform().lower()
236
+
237
+
238
+ def on_wsl():
239
+ """Return True if running inside Windows Subsystem for Linux (WSL or WSL2)."""
240
+ # Must look like Linux, not Windows
241
+ if platform.system() != "Linux":
242
+ return False
243
+
244
+
245
+ # --- Check environment variables for WSL2 ---
246
+ # False negative risk:
247
+ # Environment variables may be absent in older WSL1 installs.
248
+ # False negative likelihood: low.
249
+ if "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ:
250
+ return True
251
+
252
+ # --- Check kernel info for 'microsoft' or 'wsl' string (Fallback) ---
253
+ # False negative risk:
254
+ # Custom kernels, future Windows versions, or minimal WSL distros may omit 'microsoft' in strings.
255
+ # False negative likelihood: Very low to moderate.
256
+ try:
257
+ with open("/proc/version") as f:
258
+ version_info = f.read().lower()
259
+ if "microsoft" in version_info or "wsl" in version_info:
260
+ return True
261
+ except (IOError, OSError):
262
+ # This block would catch the PermissionError!
263
+ # It would simply 'pass' and move on.
264
+ pass
265
+
266
+
267
+ # Check for WSL-specific mounts (fallback)
268
+ """
269
+ /proc/sys/kernel/osrelease
270
+ Purpose: Contains the kernel release string. In WSL, it usually contains "microsoft" (WSL2) or "microsoft-standard" (WSL1).
271
+ Very reliable for detecting WSL1 and WSL2 unless someone compiled a custom kernel and removed the microsoft string.
272
+
273
+ False negative risk:
274
+ If /proc/sys/kernel/osrelease cannot be read due to permissions, a containerized WSL distro, or some sandboxed environment.
275
+ # False negative likelihood: Very low.
276
+ """
277
+ try:
278
+ with open("/proc/sys/kernel/osrelease") as f:
279
+ osrelease = f.read().lower()
280
+ if "microsoft" in osrelease:
281
+ return True
282
+ except (IOError, OSError):
283
+ # This block would catch the PermissionError, an FileNotFound
284
+ pass
285
+
286
+ try:
287
+ if 'microsoft' in platform.uname().release.lower():
288
+ return True
289
+ except:
290
+ pass
291
+ return False
292
+
293
+ def on_pydroid():
294
+ """Return True if running under Pydroid 3 (Android app)."""
295
+ if not on_android():
296
+ return False
297
+
298
+ exe = (sys.executable or "").lower()
299
+ if "pydroid" in exe or "ru.iiec.pydroid3" in exe:
300
+ return True
301
+
302
+ return any("pydroid" in p.lower() for p in sys.path)
303
+
304
+ def on_windows() -> bool:
305
+ """Detect if running on Windows."""
306
+ return platform.system() == 'Windows'
307
+
308
+ def on_macos() -> bool:
309
+ """Detect if running on MacOS. Does not consider on_ish_alpine()."""
310
+ return (platform.system() == 'Darwin')
311
+
312
+ def on_ish_alpine() -> bool:
313
+ """Detect if running in iSH Alpine environment on iOS."""
314
+ # platform.system() usually returns 'Linux' in iSH
315
+
316
+ # iSH runs on iOS but reports 'Linux' via platform.system()
317
+ if platform.system() != 'Linux':
318
+ return False
319
+
320
+ # On iSH, /etc/apk/ will exist. However, this is not unique to iSH as standard Alpine Linux also has this directory.
321
+ # Therefore, we need an additional check to differentiate iSH from standard Alpine.
322
+ # HIGHLY SPECIFIC iSH CHECK: Look for the unique /proc/ish/ directory.
323
+ # This directory is created by the iSH pseudo-kernel and does not exist
324
+ # on standard Alpine or other Linux distributions.
325
+ if os.path.isdir('/etc/apk/') and os.path.isdir('/proc/ish'):
326
+ # This combination is highly specific to iSH Alpine.
327
+ return True
328
+
329
+ return False
330
+
331
+ def in_repl() -> bool:
332
+ """
333
+ Detects if the code is running in the Python interactive REPL (e.g., when 'python' is typed in a console).
334
+
335
+ This function specifically checks for the Python REPL by verifying the presence of the interactive
336
+ prompt (`sys.ps1`). It returns False for other interactive terminal scenarios, such as running a
337
+ PyInstaller binary in a console.
338
+
339
+ Returns:
340
+ bool: True if running in the Python REPL; False otherwise.
341
+ """
342
+ return hasattr(sys, 'ps1')
343
+
344
+
345
+ # --- BUILD AND EXECUTABLE CHECKS ---
346
+
347
+ def as_pyinstaller():
348
+ """Detects if the Python script is running as a 'frozen' in the course of generating a PyInstaller binary executable."""
349
+ # If the app is frozen AND has the PyInstaller-specific temporary folder path
350
+ return as_frozen() and hasattr(sys, '_MEIPASS')
351
+
352
+ # The standard way to check for a frozen state:
353
+ def as_frozen():
354
+ """
355
+ Detects if the Python script is running as a 'frozen' (standalone)
356
+ executable created by a tool like PyInstaller, cx_Freeze, or Nuitka.
357
+
358
+ This check is crucial for handling file paths, finding resources,
359
+ and general environment assumptions, as a frozen executable's
360
+ structure differs significantly from a standard script execution
361
+ or a virtual environment.
362
+
363
+ The check is based on examining the 'frozen' attribute of the sys module.
364
+
365
+ Returns:
366
+ bool: True if the application is running as a frozen executable;
367
+ False otherwise.
368
+ """
369
+ return getattr(sys, 'frozen', False)
370
+
371
+ # --- Binary Characteristic Checks ---
372
+ def is_elf(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
373
+ """Checks if the currently running executable (sys.argv[0]) is a standalone PyInstaller-built ELF binary."""
374
+ # If it's a pipx installation, it is not the monolithic binary we are concerned with here.
375
+ exec_path, is_valid = check_executable_path(exec_path, debug and not suppress_debug)
376
+ if not is_valid:
377
+ return False
378
+
379
+ try:
380
+ # Check the magic number: The first four bytes of an ELF file are 0x7f, 'E', 'L', 'F' (b'\x7fELF').
381
+ # This is the most reliable way to determine if the executable is a native binary wrapper (like PyInstaller's).
382
+ magic_bytes = read_magic_bytes(exec_path, 4, debug and not suppress_debug)
383
+ if magic_bytes is None:
384
+ return False
385
+ return magic_bytes == b'\x7fELF'
386
+ except (OSError, IOError) as e:
387
+ if debug:
388
+ logging.debug("False (Exception during file check)")
389
+ return False
390
+
391
+ def is_pyz(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
392
+ """Checks if the currently running executable (sys.argv[0]) is a PYZ zipapp ."""
393
+
394
+ # If it's a pipx installation, it is not the monolithic binary we are concerned with here.
395
+ exec_path, is_valid = check_executable_path(exec_path, debug and not suppress_debug)
396
+ if not is_valid:
397
+ return False
398
+
399
+ # Check if the extension is PYZ
400
+ if not str(exec_path).endswith(".pyz"):
401
+ if debug:
402
+ logging.debug("is_pyz()=False (Not a .pyz file)")
403
+ return False
404
+
405
+ if not _check_if_zip(exec_path):
406
+ if debug:
407
+ logging.debug("False (Not a valid ZIP file)")
408
+ return False
409
+
410
+ return True
411
+
412
+ def is_windows_portable_executable(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
413
+ """
414
+ Checks if the specified path or sys.argv[0] is a Windows Portable Executable (PE) binary.
415
+ Windows Portable Executables include .exe, .dll, and other binaries.
416
+ The standard way to check for a PE is to look for the MZ magic number at the very beginning of the file.
417
+ """
418
+ exec_path, is_valid = check_executable_path(exec_path, debug and not suppress_debug)
419
+ if not is_valid:
420
+ return False
421
+ try:
422
+ magic_bytes = read_magic_bytes(exec_path, 2, debug and not suppress_debug)
423
+ if magic_bytes is None:
424
+ return False
425
+ result = magic_bytes.startswith(b"MZ")
426
+ return result
427
+ except (OSError, IOError) as e:
428
+ if debug:
429
+ logging.debug(f"is_windows_portable_executable() = False (Exception: {e})")
430
+ return False
431
+
432
+ def is_msix() -> bool:
433
+ """
434
+ Detect whether the current Python process is running inside an MSIX
435
+ (or APPX) packaged environment, such as when distributed through the
436
+ Microsoft Store.
437
+
438
+ This check works by querying the Windows package identity assigned to
439
+ AppX/MSIX containers. If the process has no package identity, Windows
440
+ returns APPMODEL_ERROR_NO_PACKAGE (15700), and the function returns False.
441
+
442
+ Returns:
443
+ bool: True if running inside an MSIX/AppX package; False otherwise.
444
+
445
+ This function cannot be dual-use for introspection as well as checking arbitrary paths.
446
+ This function is only for introspection and should accept no arguments.
447
+ """
448
+ if platform.system() != "Windows":
449
+ return False
450
+
451
+ try:
452
+ import ctypes
453
+ from ctypes import wintypes
454
+ except Exception:
455
+ return False
456
+
457
+ # Windows API function
458
+ GetCurrentPackageFullName = ctypes.windll.kernel32.GetCurrentPackageFullName
459
+ GetCurrentPackageFullName.argtypes = [
460
+ ctypes.POINTER(wintypes.UINT),
461
+ wintypes.LPWSTR
462
+ ]
463
+ GetCurrentPackageFullName.restype = wintypes.LONG
464
+
465
+ APPMODEL_ERROR_NO_PACKAGE = 15700
466
+
467
+ length = wintypes.UINT(0)
468
+
469
+ # First call: get required buffer length
470
+ rc = GetCurrentPackageFullName(ctypes.byref(length), None)
471
+
472
+ if rc == APPMODEL_ERROR_NO_PACKAGE:
473
+ return False # Not MSIX/AppX packaged
474
+
475
+ # Allocate buffer and retrieve the package full name
476
+ buffer = ctypes.create_unicode_buffer(length.value)
477
+ rc = GetCurrentPackageFullName(ctypes.byref(length), buffer)
478
+
479
+ return rc == 0
480
+
481
+
482
+ def is_macos_executable(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
483
+ """
484
+ Checks if the currently running executable is a macOS/Darwin Mach-O binary,
485
+ and explicitly excludes pipx-managed environments.
486
+ """
487
+ exec_path, is_valid = check_executable_path(exec_path, debug and not suppress_debug)
488
+ if not is_valid:
489
+ return False
490
+
491
+ try:
492
+ # Check the magic number: Mach-O binaries start with specific 4-byte headers.
493
+ # Common ones are: b'\xfe\xed\xfa\xce' (32-bit) or b'\xfe\xed\xfa\xcf' (64-bit)
494
+
495
+ magic_bytes = read_magic_bytes(exec_path, 4, debug and not suppress_debug)
496
+ if magic_bytes is None:
497
+ return False
498
+ # Common Mach-O magic numbers (including their reversed-byte counterparts)
499
+ MACHO_MAGIC = {
500
+ b'\xfe\xed\xfa\xce', # MH_MAGIC
501
+ b'\xce\xfa\xed\xfe', # MH_CIGAM (byte-swapped)
502
+ b'\xfe\xed\xfa\xcf', # MH_MAGIC_64
503
+ b'\xcf\xfa\xed\xfe', # MH_CIGAM_64 (byte-swapped)
504
+ }
505
+
506
+ is_macho = magic_bytes in MACHO_MAGIC
507
+
508
+
509
+ return is_macho
510
+
511
+ except (OSError, IOError) as e:
512
+ if debug:
513
+ logging.debug("is_macos_executable() = False (Exception during file check)")
514
+ return False
515
+
516
+
517
+ def is_pipx(exec_path: Path | str | None = None, debug: bool = False, suppress_debug: bool = True) -> bool:
518
+ """Checks if the executable is running from a pipx managed environment."""
519
+ exec_path, is_valid = check_executable_path(exec_path, debug and not suppress_debug, check_pipx=False)
520
+ # check_pipx arg should be false when calling from inside of is_pipx() to avoid recursion error
521
+ # For safety, check_executable_path() guards against this.
522
+ if not is_valid:
523
+ return False
524
+
525
+ try:
526
+ interpreter_path = Path(sys.executable).resolve()
527
+ pipx_bin_path, pipx_venv_base_path = _get_pipx_paths()
528
+
529
+ # Normalize paths for comparison
530
+ norm_exec_path = str(exec_path).lower()
531
+ norm_interp_path = str(interpreter_path).lower()
532
+ pipx_venv_base_str = str(pipx_venv_base_path).lower()
533
+
534
+ if debug:
535
+ logging.debug(f"EXEC_PATH: {exec_path}")
536
+ logging.debug(f"INTERP_PATH: {interpreter_path}")
537
+ logging.debug(f"PIPX_BIN_PATH: {pipx_bin_path}")
538
+ logging.debug(f"PIPX_VENV_BASE: {pipx_venv_base_path}")
539
+ is_in_pipx_venv_base = norm_interp_path.startswith(pipx_venv_base_str)
540
+ logging.debug(f"Interpreter path resides somewhere within the pipx venv base hierarchy: {is_in_pipx_venv_base}")
541
+ logging.debug(
542
+ f"This determines whether the current interpreter is managed by pipx: {is_in_pipx_venv_base}"
543
+ )
544
+ if "pipx/venvs" in norm_exec_path or "pipx/venvs" in norm_interp_path:
545
+ if debug:
546
+ logging.debug("is_pipx() is True // Signature Check")
547
+ return True
548
+
549
+ if norm_interp_path.startswith(pipx_venv_base_str):
550
+ if debug:
551
+ logging.debug("is_pipx() is True // Interpreter Base Check")
552
+ return True
553
+
554
+ if norm_exec_path.startswith(pipx_venv_base_str):
555
+ if debug:
556
+ logging.debug("is_pipx() is True // Executable Base Check")
557
+ return True
558
+
559
+ if debug:
560
+ logging.debug("is_pipx() is False")
561
+ return False
562
+
563
+ except Exception:
564
+ if debug:
565
+ logging.debug("False (Exception during pipx check)")
566
+
567
+ def is_python_script(path: Path | str | None = None, debug: bool = False, suppress_debug: bool =False) -> bool:
568
+ """
569
+ Checks if the specified path or running script is a Python source file (.py).
570
+
571
+ By default, checks the running script (`sys.argv[0]`). If a specific `path` is
572
+ provided, checks that path instead. Uses `Path.resolve()` for stable path handling.
573
+
574
+ Args:
575
+ path: Optional; path to the file to check (str or Path). If None, defaults to `sys.argv[0]`.
576
+ debug: If True, prints the path being checked.
577
+
578
+ Returns:
579
+ bool: True if the specified or default path is a Python source file (.py); False otherwise.
580
+ """
581
+ exec_path, is_valid = check_executable_path(path, debug and not suppress_debug, check_pipx=False)
582
+ if not is_valid:
583
+ return False
584
+ return exec_path.suffix.lower() == '.py'
585
+
586
+ # --- File encoding check ---
587
+ def is_binary(path:str|Path|None=None)->bool:
588
+ """
589
+ Target file is encoded as binary.
590
+ """
591
+ pass
592
+
593
+ def is_ascii(path:str|Path|None=None)->bool:
594
+ """
595
+ Target file is encoded as ascii, plaintext.
596
+ """
597
+ pass
598
+
599
+ # --- Interpreter Check ---
600
+
601
+ def interp_path(debug: bool = False) -> str:
602
+ """
603
+ Returns the path to the Python interpreter binary and optionally prints it.
604
+
605
+ This function wraps `sys.executable` to provide the path to the interpreter
606
+ (e.g., '/data/data/com.termux/files/usr/bin/python3' in Termux or the embedded
607
+ interpreter in a frozen executable). If the path is empty (e.g., in some embedded
608
+ or sandboxed environments), an empty string is returned.
609
+
610
+ Args:
611
+ print_path: If True, prints the interpreter path to stdout.
612
+
613
+ Returns:
614
+ str: The path to the Python interpreter binary, or an empty string if unavailable.
615
+ """
616
+ path = sys.executable
617
+ if debug:
618
+ logging.debug(f"Python interpreter path: {path}")
619
+ return path
620
+
621
+ # --- TTY Check ---
622
+ def interactive_terminal_is_available():
623
+ """
624
+ Check if the script is running in an interactive terminal.
625
+ Assumpton:
626
+ If interactive_terminal_is_available() returns True,
627
+ then typer.prompt() or input() will work reliably,
628
+ without getting lost in a log or lost entirely.
629
+
630
+ Solution correctly identifies that true interactivity requires:
631
+ (1) a TTY (potential) connection
632
+ (2) the ability to execute
633
+ (3) the ability to read I/O
634
+ (4) ignores known limitatons in restrictive environments
635
+
636
+ Jargon:
637
+ A TTY, short for Teletypewriter or TeleTYpe,
638
+ is a conceptual or physical device that serves
639
+ as the interface for a user to interact with
640
+ a computer system.
641
+ """
642
+
643
+ # --- 1. Edge Case/Known Environment Check ---
644
+ # Address walmart demo unit edge case, fast check, though this might hamstring othwrwise successful processes
645
+ if user_darrin_deyoung():
646
+ return False
647
+
648
+ # --- 2. Core TTY Check (Is a terminal attached?) ---
649
+ # Check if a tty is attached to stdin AND stdout. This is the minimum requirement.
650
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
651
+ return False
652
+
653
+ # --- 3. Uvicorn/Server Occupancy Check (Crucial for your issue) ---
654
+ # If the TTY is attached, but the process is currently serving an ASGI application
655
+ # (like Uvicorn running your FastAPI app), it is NOT interactively available for new CLI input.
656
+ if is_running_in_uvicorn():
657
+ # This prevents the CLI from "steamrolling" the prompts when the user presses Fetch.
658
+ return False
659
+
660
+ # Check of a new shell can be launched to print stuff
661
+ if not can_spawn_shell():
662
+ return False
663
+
664
+ return sys.stdin.isatty() and sys.stdout.isatty()
665
+
666
+ def is_running_in_uvicorn():
667
+ # Uvicorn, Hypercorn, Daphne, etc.
668
+ """
669
+ Heuristic check to see if the current code is running inside a Uvicorn worker process.
670
+ This is highly useful for context-aware interactivity checks.
671
+ """
672
+ return getattr(sys, '_uvicorn_workers', None) is not None
673
+
674
+ def user_darrin_deyoung():
675
+ """Common demo unit undicator, edge case that is unable to launch terminal"""
676
+ # Enable teating on non-Windows, non-demo systems
677
+ # where this function would otherwise return False.
678
+ # Linux: `export USER_DARRIN_DEYOUNG=True`
679
+ if os.getenv('USER_DARRIN_DEYOUNG','').lower() == "true":
680
+ print("env var USER_DARRIN_DEYOUNG is set to True.")
681
+ return True
682
+ # Darrin Deyoung is the typical username on demo-mode Windows systems
683
+ if not on_windows():
684
+ return False
685
+ username = getpass.getuser()
686
+ return username.lower() == "darrin deyoung"
687
+
688
+ @cache
689
+ def can_spawn_shell_lite()->bool:
690
+ """Check if a shell command can be executed successfully."""
691
+ return shutil.which('cmd.exe' if on_windows() else "sh") is not None
692
+
693
+ @cache
694
+ def can_spawn_shell(override_known:bool=False)->bool:
695
+ """Check if a shell command can be executed successfully."""
696
+
697
+ cmd = "cmd.exe /c exit 0" if on_windows() else "true"
698
+ try:
699
+ # Use a simple, universally applicable command with shell=True
700
+ # 'true' on Linux/macOS, or a basic command on Windows via cmd.exe
701
+ # A simple 'echo' or 'exit 0' would also work
702
+ result = subprocess.run(
703
+ cmd,
704
+ shell=True, # <--- ESSENTIAL for cross-platform reliability
705
+ stdout=subprocess.PIPE,
706
+ stderr=subprocess.PIPE,
707
+ timeout=3,
708
+ )
709
+ success = (result.returncode == 0)
710
+
711
+ except subprocess.TimeoutExpired:
712
+ print("Shell spawn failed: TimeoutExpired")
713
+ success = False
714
+ except subprocess.SubprocessError:
715
+ print("Shell spawn failed: SubprocessError")
716
+ success = False
717
+ except OSError:
718
+ print("Shell spawn failed: OSError (likely permission or missing binary)")
719
+ success = False
720
+ return success
721
+
722
+
723
+ # --- Browser Check ---
724
+ def web_browser_is_available() -> bool:
725
+ """ Check if a web browser can be launched in the current environment."""
726
+ try:
727
+ # 1. Standard Python check
728
+ webbrowser.get()
729
+ return True
730
+ except webbrowser.Error:
731
+ pass
732
+ except Exception as e:
733
+ pass
734
+
735
+ # Fallback needed. Check for external launchers.
736
+ # 2. Termux specific check
737
+ if on_termux() and shutil.which("termux-open-url"):
738
+ return True
739
+ # 3. General Linux check
740
+ if shutil.which("xdg-open") or shutil.which("open") or shutil.which("start"):
741
+ return True
742
+ return False
743
+
744
+
745
+ # --- LAUNCH MECHANISMS BASED ON ENVIRONMENT ---
746
+
747
+ def edit_textfile(path: Path | str | None = None, background: Optional[bool] = None) -> None:
748
+ """
749
+ Opens a file with the environment's default application.
750
+
751
+ Logic:
752
+ - If background is None:
753
+ - Blocks (waits) if in REPL or Interactive Terminal (supports nano/vim).
754
+ - Runs backgrounded if in a GUI/headless environment.
755
+ - If background is True/False: Manual override.
756
+
757
+ Ensures line-ending compatibility and dependency installation in
758
+ constrained environments (Termux, iSH).
759
+ """
760
+ if path is None:
761
+ return
762
+
763
+ path = Path(path).resolve()
764
+
765
+ # --- 1. Intelligent Context Detection ---
766
+ if background is None:
767
+ # Detect if we have a TTY/REPL to determine if blocking is necessary
768
+ if in_repl() or interactive_terminal_is_available():
769
+ is_async = False
770
+ else:
771
+ is_async = True
772
+ else:
773
+ is_async = background
774
+
775
+ # Choose runner: Popen for fire-and-forget, run for blocking
776
+ launcher = subprocess.Popen if is_async else subprocess.run
777
+
778
+ try:
779
+
780
+ # --- Windows ---
781
+ if on_windows():
782
+
783
+ # Force resolve to handle MSIX VFS redirection
784
+ # Force resolve AND normalize slashes for the Windows API
785
+ abs_path = os.path.normpath(str(Path(path).resolve()))
786
+ #success = False
787
+
788
+ # 0: Special case: MSIX files (Sandboxed, os.startfile() known to fail
789
+ if is_msix(): #
790
+
791
+ try:
792
+ # Use the explicit path to System32 notepad to bypass environment issues
793
+ system_notepad = os.path.join(os.environ.get('SystemRoot', 'C:\\Windows'), 'System32', 'notepad.exe')
794
+ subprocess.Popen([system_notepad, abs_path])
795
+ return
796
+ except Exception as e:
797
+ print(f"Notepad launch failed via MSIX package: {e}")
798
+
799
+ # 1. Primary: System Default (Natively non-blocking/async)
800
+ try:
801
+ # os.startfile is natively non-blocking (async)
802
+ os.startfile(abs_path)
803
+ #success = True
804
+ return
805
+ except Exception as e:
806
+ # This catches the "No program associated" or "Access denied" errors
807
+ print(f"os.startfile() failed in pyhabitat.edit_textfile(): {e}")
808
+
809
+ # 2. Secondary: Force Notepad (Guaranteed fallback)
810
+ # Use Popen to ENSURE it never blocks the caller, regardless of REPL status.
811
+ try:
812
+ subprocess.Popen(['notepad.exe', abs_path])
813
+ #success = True
814
+ return
815
+ except Exception as e:
816
+ print(f"notepad.exe failed in pyhabitat.edit_textfile(): {e}")
817
+
818
+ print(f"\n[Error] Windows could not open the file: {abs_path}")
819
+
820
+ # --- Termux (Android) ---
821
+ elif on_termux():
822
+ try:
823
+ # Try to run directly assuming tools exist
824
+ _run_dos2unix(path)
825
+ subprocess.run(['nano', str(path)])
826
+ except FileNotFoundError:
827
+ # Fallback: Install missing tools
828
+ # Using -y ensures the package manager doesn't hang waiting for a 'Yes'
829
+ subprocess.run(['pkg', 'install', '-y', 'dos2unix', 'nano'],
830
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
831
+ _run_dos2unix(path)
832
+ subprocess.run(['nano', str(path)])
833
+
834
+ # --- iSH (iOS Alpine) ---
835
+ elif on_ish_alpine():
836
+ try:
837
+ _run_dos2unix(path)
838
+ subprocess.run(['nano', str(path)])
839
+ except FileNotFoundError:
840
+ # Alpine uses 'apk add'
841
+ subprocess.run(['apk', 'add', 'dos2unix', 'nano'],
842
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
843
+ _run_dos2unix(path)
844
+ subprocess.run(['nano', str(path)])
845
+
846
+ # --- Standard Desktop Linux ---
847
+ elif on_linux():
848
+ _run_dos2unix(path)
849
+ success = False
850
+
851
+ # 1. Try System Default (xdg-open)
852
+ # We use subprocess.run here to check if the OS actually knows how to handle the file.
853
+ try:
854
+ # capture_output=True keeps the 'no mailcap rules' error out of the user's console
855
+ subprocess.run(['xdg-open', str(path)], check=True, capture_output=True)
856
+ success = True
857
+ except (subprocess.CalledProcessError, FileNotFoundError, Exception):
858
+ # If xdg-open fails (like the JSON error you saw), we move to manual fallbacks
859
+ pass
860
+
861
+ if not success:
862
+ # 2. Fallback Ladder: Common GUI Editors
863
+ # These are safe to background (using the 'launcher' Popen/run logic)
864
+ # Prioritize standalone editors over IDEs
865
+ gui_editors = ['gedit', 'mousepad', 'kate', 'xed', 'code']
866
+ for editor in gui_editors:
867
+ if shutil.which(editor):
868
+ # launcher will be Popen if we are in a GUI, or run if in a TTY
869
+ launcher([editor, str(path)])
870
+ success = True
871
+ break
872
+
873
+ if not success:
874
+ # 3. Final Fallback: Terminal Editor
875
+ # This MUST be blocking (subprocess.run) to work in a TTY/REPL context.
876
+ # We don't spawn a new window to avoid environmental/SSH crashes.
877
+ if shutil.which('nano'):
878
+ # If we are in a GUI, the user might need to look at the terminal they launched from
879
+ if is_async:
880
+ print(f"\n[Note] No GUI editor found. Opening {path.name} in nano within the terminal.")
881
+
882
+ subprocess.run(['nano', str(path)])
883
+ success = True
884
+ else:
885
+ # Absolute last resort
886
+ print(f"\n[Error] No suitable editor (GUI or Terminal) found. File saved at: {path}")
887
+
888
+ # --- macOS ---
889
+ elif on_macos():
890
+ _run_dos2unix(path)
891
+ # 'open' on Mac usually returns immediately for GUI apps anyway,
892
+ # but using our launcher keeps the Popen logic consistent.
893
+ try:
894
+ launcher(['open', str(path)])
895
+ except Exception:
896
+ # Terminal fallback for Mac if 'open' fails (very rare)
897
+ if shutil.which('nano'):
898
+ subprocess.run(['nano', str(path)])
899
+ else:
900
+ print("Unsupported operating system.")
901
+
902
+ except Exception as e:
903
+ print(f"The file could not be opened: {e}")
904
+
905
+ # --- Helper Functions ---
906
+ def _run_dos2unix(path: Path | str | None = None):
907
+ """Attempt to run dos2unix, failing silently if not installed."""
908
+
909
+ path = Path(path).resolve()
910
+
911
+ try:
912
+ # We rely on shutil.which not being needed, as this is a robust built-in utility on most targets
913
+ # The command won't raise an exception unless the process itself fails, not just if the utility isn't found.
914
+ # We also don't use check=True here to allow silent failure if the utility is missing (e.g., minimalist Linux).
915
+ subprocess.run(['dos2unix', str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
916
+ except FileNotFoundError:
917
+ # This will be raised if 'dos2unix' is not on the system PATH
918
+ pass
919
+ except Exception:
920
+ # Catch other subprocess errors (e.g. permission issues)
921
+ pass
922
+
923
+ def show_system_explorer(path: str | Path = None) -> None:
924
+ """
925
+ Opens the system file explorer (File Explorer, Finder, or Nautilus/etc.)
926
+ to the directory containing the exported reports.
927
+ """
928
+ # 1. Standardize to a Path object immediately
929
+ if path is None:
930
+ path = Path.cwd()
931
+ else:
932
+ path = Path(path)
933
+
934
+ # 2. Smart Trim: If they pointed to a file, we want to open the folder it's in
935
+ if path.is_file():
936
+ path = path.parent
937
+
938
+ # Ensure it exists before we try to open it (prevents shell crashes)
939
+ if not path.exists():
940
+ print(f"Error: Path does not exist: {path}")
941
+ return
942
+
943
+ # Ensure path is a string and expanded
944
+ path = str(Path(path).expanduser().resolve())
945
+
946
+
947
+ try:
948
+ if on_wsl():
949
+ win_path = subprocess.check_output(["wslpath", "-w", path]).decode().strip()
950
+ subprocess.Popen(["explorer.exe", win_path])
951
+ elif on_windows():
952
+ # use os.startfile for the most native Windows experience
953
+ os.startfile(path)
954
+ elif sys.platform == "darwin":
955
+ # macOS
956
+ subprocess.Popen(["open", str(path)])
957
+
958
+ # Android (Termux)
959
+ elif on_termux():
960
+ # termux-open passes the intent to the Android system explorer
961
+ subprocess.Popen(["termux-open", path])
962
+ return
963
+
964
+ else:
965
+ # Linux/Other: pyhabitat or xdg-open fallback
966
+ # Using xdg-open is the standard for Nautilus, Dolphin, Thunar, etc.
967
+ subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
968
+ except Exception as e:
969
+ print(f"Could not open system explorer. Path: {path}. Error: {e}")
970
+
971
+
972
+ def read_magic_bytes(path: str, length: int = 4, debug: bool = False) -> bytes | None:
973
+ """Return the first few bytes of a file for type detection.
974
+ Returns None if the file cannot be read or does not exist.
975
+ """
976
+ try:
977
+ with open(path, "rb") as f:
978
+ magic = f.read(length)
979
+ if debug:
980
+ logging.debug(f"Magic bytes: {magic!r}")
981
+ return magic
982
+ except Exception as e:
983
+ if debug:
984
+ logging.debug(f"False (Error during file check: {e})")
985
+ #return False # not typesafe
986
+ #return b'' # could be misunderstood as what was found
987
+ return None # no way to conflate that this was a legitimate error
988
+
989
+ def _get_pipx_paths():
990
+ """
991
+ Returns the configured/default pipx binary and home directories.
992
+ Assumes you indeed have a pipx dir.
993
+ """
994
+ # 1. PIPX_BIN_DIR (where the symlinks live, e.g., ~/.local/bin)
995
+ pipx_bin_dir_str = os.environ.get('PIPX_BIN_DIR')
996
+ if pipx_bin_dir_str:
997
+ pipx_bin_path = Path(pipx_bin_dir_str).resolve()
998
+ else:
999
+ # Default binary path (common across platforms for user installs)
1000
+ pipx_bin_path = Path.home() / '.local' / 'bin'
1001
+
1002
+ # 2. PIPX_HOME (where the isolated venvs live, e.g., ~/.local/pipx/venvs)
1003
+ pipx_home_str = os.environ.get('PIPX_HOME')
1004
+ if pipx_home_str:
1005
+ # PIPX_HOME is the base, venvs are in PIPX_HOME/venvs
1006
+ pipx_venv_base = Path(pipx_home_str).resolve() / 'venvs'
1007
+ else:
1008
+ # Fallback to the modern default for PIPX_HOME (XDG standard)
1009
+ # Note: pipx is smart and may check the older ~/.local/pipx too
1010
+ # but the XDG one is the current standard.
1011
+ pipx_venv_base = Path.home() / '.local' / 'share' / 'pipx' / 'venvs'
1012
+
1013
+ return pipx_bin_path, pipx_venv_base.resolve()
1014
+
1015
+
1016
+ def _check_if_zip(path: Path | str | None) -> bool:
1017
+ """Checks if the file at the given path is a valid ZIP archive."""
1018
+ if path is None:
1019
+ return False
1020
+ path = Path(path).resolve()
1021
+
1022
+ try:
1023
+ return zipfile.is_zipfile(path)
1024
+ except Exception:
1025
+ # Handle cases where the path might be invalid, or other unexpected errors
1026
+ return False
1027
+
1028
+ def check_executable_path(exec_path: Path | str | None,
1029
+ debug: bool = False,
1030
+ check_pipx: bool = True
1031
+ ) -> tuple[Path | None, bool]: #compensate with __future__, may cause type checker issues
1032
+ """
1033
+ Helper function to resolve an executable path and perform common checks.
1034
+
1035
+ Returns:
1036
+ tuple[Path | None, bool]: (Resolved path, is_valid)
1037
+ - Path: The resolved Path object, or None if invalid
1038
+ - bool: Whether the path should be considered valid for subsequent checks
1039
+ """
1040
+ # 1. Determine path
1041
+ if exec_path is None:
1042
+ exec_path = Path(sys.argv[0]).resolve() if sys.argv[0] and sys.argv[0] != '-c' else None
1043
+ else:
1044
+ exec_path = Path(exec_path).resolve()
1045
+
1046
+ if debug:
1047
+ logging.debug(f"Checking executable path: {exec_path}")
1048
+
1049
+ # 2. Handle missing path
1050
+ if exec_path is None:
1051
+ if debug:
1052
+ logging.debug("check_executable_path() returns (None, False) // exec_path is None")
1053
+ return None, False
1054
+
1055
+ # 3. Ensure path actually exists and is a file
1056
+ if not exec_path.is_file():
1057
+ if debug:
1058
+ logging.debug("check_executable_path() returns (exec_path, False) // exec_path is not a file")
1059
+ return exec_path, False
1060
+
1061
+ # 4. Avoid recursive pipx check loops
1062
+ # This guard ensures we don’t recursively call check_executable_path()
1063
+ # via is_pipx() -> check_executable_path() -> is_pipx() -> ...
1064
+ if check_pipx:
1065
+ caller = sys._getframe(1).f_code.co_name
1066
+ if caller != "is_pipx":
1067
+ if is_pipx(exec_path, debug):
1068
+ if debug:
1069
+ logging.debug("check_executable_path() returns (exec_path, False) // is_pipx(exec_path) is True")
1070
+ return exec_path, False
1071
+
1072
+ return exec_path, True
1073
+
1074
+
1075
+ def main(path=None, debug=False):
1076
+ from pyhabitat.reporting import report
1077
+ report(path=path, debug=debug)
1078
+
1079
+ if __name__ == "__main__":
1080
+ main(debug=True)