blindwindow 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blindwindow-0.1.0/MANIFEST.in +2 -0
- blindwindow-0.1.0/PKG-INFO +44 -0
- blindwindow-0.1.0/README.md +16 -0
- blindwindow-0.1.0/pyproject.toml +114 -0
- blindwindow-0.1.0/setup.cfg +4 -0
- blindwindow-0.1.0/src/blindwindow/VERSION +2 -0
- blindwindow-0.1.0/src/blindwindow/__init__.py +80 -0
- blindwindow-0.1.0/src/blindwindow/__main__.py +7 -0
- blindwindow-0.1.0/src/blindwindow/_version.py +23 -0
- blindwindow-0.1.0/src/blindwindow/cli.py +152 -0
- blindwindow-0.1.0/src/blindwindow/config.py +9 -0
- blindwindow-0.1.0/src/blindwindow/context.py +20 -0
- blindwindow-0.1.0/src/blindwindow/core.py +3 -0
- blindwindow-0.1.0/src/blindwindow/gui.py +215 -0
- blindwindow-0.1.0/src/blindwindow/helpers.py +3 -0
- blindwindow-0.1.0/src/blindwindow/logging_setup.py +177 -0
- blindwindow-0.1.0/src/blindwindow.egg-info/PKG-INFO +44 -0
- blindwindow-0.1.0/src/blindwindow.egg-info/SOURCES.txt +20 -0
- blindwindow-0.1.0/src/blindwindow.egg-info/dependency_links.txt +1 -0
- blindwindow-0.1.0/src/blindwindow.egg-info/entry_points.txt +2 -0
- blindwindow-0.1.0/src/blindwindow.egg-info/requires.txt +8 -0
- blindwindow-0.1.0/src/blindwindow.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blindwindow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Route console stderr and stdout to blindwindow, to either duplicate or display what is otherwise hidden. Namely for MSIX deployed CLI's
|
|
5
|
+
Author-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
6
|
+
Maintainer-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Classifier: Development Status :: 3 - Alpha
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: dworshak-config>=0.2.8
|
|
22
|
+
Requires-Dist: pyhabitat>=1.3.9
|
|
23
|
+
Requires-Dist: typer>=0.27.0
|
|
24
|
+
Requires-Dist: typer-helptree>=0.2.12
|
|
25
|
+
Requires-Dist: maxson-gui-utils>=0.1.5
|
|
26
|
+
Provides-Extra: gui
|
|
27
|
+
Requires-Dist: maxson_gui_utils>=0.1.4; extra == "gui"
|
|
28
|
+
|
|
29
|
+
# Blindwindow
|
|
30
|
+
|
|
31
|
+
Render console stderr and stdout to a TKinter based REPL widget masquerading as a log pange.
|
|
32
|
+
|
|
33
|
+
Use Case: When calling a CLI packaged inside of a MSIX that has been delivered via the Microsoft Store. This use case typically does not allow prints.
|
|
34
|
+
Example: `pdflinkcheck.exe serve --help`
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Helptree
|
|
39
|
+
|
|
40
|
+
See the `blindwindow` Typer CLI structure.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
blindwindow helptree
|
|
44
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Blindwindow
|
|
2
|
+
|
|
3
|
+
Render console stderr and stdout to a TKinter based REPL widget masquerading as a log pange.
|
|
4
|
+
|
|
5
|
+
Use Case: When calling a CLI packaged inside of a MSIX that has been delivered via the Microsoft Store. This use case typically does not allow prints.
|
|
6
|
+
Example: `pdflinkcheck.exe serve --help`
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Helptree
|
|
11
|
+
|
|
12
|
+
See the `blindwindow` Typer CLI structure.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
blindwindow helptree
|
|
16
|
+
```
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "blindwindow"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Route console stderr and stdout to blindwindow, to either duplicate or display what is otherwise hidden. Namely for MSIX deployed CLI's"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
"dworshak-config>=0.2.8",
|
|
10
|
+
"pyhabitat>=1.3.9",
|
|
11
|
+
"typer>=0.27.0",
|
|
12
|
+
"typer-helptree>=0.2.12",
|
|
13
|
+
"maxson-gui-utils>=0.1.5",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
license = "MIT"
|
|
17
|
+
license-files = ["LICENSE"]
|
|
18
|
+
|
|
19
|
+
authors = [
|
|
20
|
+
{ name = "George Clayton Bennett", email = "george.bennett@memphistn.gov" }
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
maintainers = [
|
|
24
|
+
{ name = "George Clayton Bennett", email = "george.bennett@memphistn.gov" }
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Programming Language :: Python :: 3.14",
|
|
35
|
+
"Operating System :: OS Independent",
|
|
36
|
+
"Intended Audience :: Developers",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
"Development Status :: 3 - Alpha",
|
|
39
|
+
#"Development Status :: 4 - Beta",
|
|
40
|
+
#"Development Status :: 5 - Production/Stable",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
keywords = [
|
|
44
|
+
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#[project.urls]
|
|
48
|
+
#Homepage = ""
|
|
49
|
+
#Repository = ""
|
|
50
|
+
#Issues = ""
|
|
51
|
+
#Changelog = ""
|
|
52
|
+
|
|
53
|
+
[project.scripts]
|
|
54
|
+
"blindwindow" = "blindwindow.__main__:app"
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
test = [
|
|
58
|
+
"pytest>=8.0.0",
|
|
59
|
+
"pytest-cov>=4.1.0",
|
|
60
|
+
]
|
|
61
|
+
dev = [
|
|
62
|
+
{ include-group = "test" },
|
|
63
|
+
"build>=1.3.0",
|
|
64
|
+
"pyinstaller>=6.17.0 ; platform_system == 'Linux' and platform_machine != 'aarch64'",
|
|
65
|
+
"shiv>=1.0.8",
|
|
66
|
+
"ruff>=0.7.0 ; platform_system == 'Linux' and platform_machine != 'aarch64'",
|
|
67
|
+
"maxson-build-utils>=0.1.33",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[project.optional-dependencies]
|
|
71
|
+
gui = [
|
|
72
|
+
"maxson_gui_utils>=0.1.4",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
[build-system]
|
|
77
|
+
requires = ["setuptools>=64", "wheel"]
|
|
78
|
+
build-backend = "setuptools.build_meta"
|
|
79
|
+
|
|
80
|
+
[tool.uv.sources]
|
|
81
|
+
"blindwindow" = { path = "src/blindwindow" }
|
|
82
|
+
|
|
83
|
+
#[tool.setuptools]
|
|
84
|
+
#packages = [""blindwindow""]
|
|
85
|
+
|
|
86
|
+
[tool.setuptools.packages.find]
|
|
87
|
+
where = ["src"]
|
|
88
|
+
|
|
89
|
+
[tool.setuptools.dynamic]
|
|
90
|
+
version = { file = "src/blindwindow/VERSION" }
|
|
91
|
+
|
|
92
|
+
[tool.setuptools.package-data]
|
|
93
|
+
"blindwindow" = [
|
|
94
|
+
"data/*",
|
|
95
|
+
"data/icons/*",
|
|
96
|
+
"data/icons/**/*",
|
|
97
|
+
"data/themes/*",
|
|
98
|
+
"data/themes/**/*",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[tool.maxson-build-utils.names]
|
|
102
|
+
import = "blindwindow"
|
|
103
|
+
pretty = "Blindwindow"
|
|
104
|
+
|
|
105
|
+
[tool.maxson-build-utils.pyinstaller]
|
|
106
|
+
mode = "onedir"
|
|
107
|
+
windowed = false
|
|
108
|
+
collect_data_pkgs = ["blindwindow"]
|
|
109
|
+
collect_binary_pkgs = []
|
|
110
|
+
|
|
111
|
+
[tool.maxson-build-utils.packaging.msix]
|
|
112
|
+
publisher = "CN=Development"
|
|
113
|
+
publisher_display_name = "George Clayton Bennett"
|
|
114
|
+
identity_name = "GeorgeClaytonBennett.Blindwindow"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# src/blindwindow/__init__.py
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
|
|
9
|
+
# 1. Clean public-facing mapping
|
|
10
|
+
__all__ = [
|
|
11
|
+
"__version__",
|
|
12
|
+
|
|
13
|
+
"__gui_easteregg_enabled__",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_easteregg_env() -> bool:
|
|
18
|
+
"""Helper to dynamically read environment state at call-time."""
|
|
19
|
+
env_flag = os.environ.get("BLINDWINDOW_GUI_EASTEREGG", "").strip().lower()
|
|
20
|
+
return env_flag in ("true", "1", "yes", "on")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# 2. Fully dynamic attribute routing
|
|
24
|
+
def __getattr__(name: str):
|
|
25
|
+
|
|
26
|
+
# Dynamic boolean evaluation for the breadcrumb attribute
|
|
27
|
+
if name == "__gui_easteregg_enabled__":
|
|
28
|
+
return _check_easteregg_env()
|
|
29
|
+
|
|
30
|
+
# Dynamic lookups for the GUI function invocation
|
|
31
|
+
if name == "start_gui":
|
|
32
|
+
|
|
33
|
+
def _missing_gui():
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"start_gui requires pyhabitat and a Tkinter-capable environment"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_missing_gui.__name__ = "start_gui"
|
|
39
|
+
_missing_gui.__doc__ = (
|
|
40
|
+
"GUI support is unavailable in this environment."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if _check_easteregg_env():
|
|
44
|
+
try:
|
|
45
|
+
import pyhabitat
|
|
46
|
+
|
|
47
|
+
if pyhabitat.tkinter_is_available():
|
|
48
|
+
from .gui import start_gui
|
|
49
|
+
|
|
50
|
+
return start_gui
|
|
51
|
+
except ImportError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
return _missing_gui
|
|
55
|
+
|
|
56
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# 3. Dynamic introspection reflecting runtime changes
|
|
60
|
+
def __dir__():
|
|
61
|
+
exported = list(__all__)
|
|
62
|
+
if _check_easteregg_env():
|
|
63
|
+
exported.append("start_gui")
|
|
64
|
+
|
|
65
|
+
return sorted(
|
|
66
|
+
exported
|
|
67
|
+
+ [
|
|
68
|
+
"__builtins__",
|
|
69
|
+
"__cached__",
|
|
70
|
+
"__doc__",
|
|
71
|
+
"__file__",
|
|
72
|
+
"__getattr__",
|
|
73
|
+
"__dir__",
|
|
74
|
+
"__loader__",
|
|
75
|
+
"__name__",
|
|
76
|
+
"__package__",
|
|
77
|
+
"__path__",
|
|
78
|
+
"__spec__",
|
|
79
|
+
]
|
|
80
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
# src/blindwindow/_version.py
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .context import APP_NAME
|
|
5
|
+
def get_version() -> str:
|
|
6
|
+
try:
|
|
7
|
+
version_file = Path(__file__).parent / "VERSION"
|
|
8
|
+
if version_file.exists():
|
|
9
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
10
|
+
except Exception:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
# Try metadata (Installed)
|
|
14
|
+
try:
|
|
15
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
16
|
+
return version(APP_NAME)
|
|
17
|
+
except (ImportError, PackageNotFoundError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
return "0.0.0-unknown"
|
|
22
|
+
|
|
23
|
+
__version__ = get_version()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# src/blindwindow/cli.py
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import pyhabitat
|
|
11
|
+
import typer
|
|
12
|
+
from typer.models import OptionInfo
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from typer_helptree import add_typer_helptree
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .context import APP_NAME, DESCRIPTION_STR
|
|
18
|
+
from .logging_setup import (
|
|
19
|
+
configure_logging_all_debug,
|
|
20
|
+
configure_logging_for_application,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
console_stderr = Console(stderr=True)
|
|
26
|
+
console_stdout = Console()
|
|
27
|
+
|
|
28
|
+
# Force Rich to always enable colors, even when running from a .pyz bundle.
|
|
29
|
+
os.environ["FORCE_COLOR"] = "1"
|
|
30
|
+
|
|
31
|
+
# Optional but helpful for full terminal feature detection.
|
|
32
|
+
os.environ["TERM"] = "xterm-256color"
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name=APP_NAME,
|
|
36
|
+
help=f"{DESCRIPTION_STR} (v{__version__})",
|
|
37
|
+
add_completion=False,
|
|
38
|
+
invoke_without_command=True,
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
context_settings={
|
|
41
|
+
"ignore_unknown_options": True,
|
|
42
|
+
"allow_extra_args": True,
|
|
43
|
+
"help_option_names": ["-h", "--help"],
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback(invoke_without_command=True)
|
|
49
|
+
def main(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
version: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--version",
|
|
54
|
+
"-V",
|
|
55
|
+
help="Show application version and exit.",
|
|
56
|
+
),
|
|
57
|
+
debug: bool = typer.Option(
|
|
58
|
+
False,
|
|
59
|
+
"--debug",
|
|
60
|
+
"-d",
|
|
61
|
+
help="Enable debug level logs for app.",
|
|
62
|
+
),
|
|
63
|
+
all_debug: bool = typer.Option(
|
|
64
|
+
False,
|
|
65
|
+
"--all-debug",
|
|
66
|
+
help="Enable debug logs for app AND dependencies.",
|
|
67
|
+
),
|
|
68
|
+
verbose: bool = typer.Option(
|
|
69
|
+
False,
|
|
70
|
+
"--verbose",
|
|
71
|
+
"-v",
|
|
72
|
+
help="Enable verbose info level logs.",
|
|
73
|
+
),
|
|
74
|
+
log_file: Path | None = typer.Option(
|
|
75
|
+
None,
|
|
76
|
+
"--log-file",
|
|
77
|
+
help="Custom path to output log file.",
|
|
78
|
+
),
|
|
79
|
+
):
|
|
80
|
+
if version:
|
|
81
|
+
typer.echo(__version__)
|
|
82
|
+
raise typer.Exit()
|
|
83
|
+
|
|
84
|
+
if all_debug:
|
|
85
|
+
configure_logging_all_debug()
|
|
86
|
+
else:
|
|
87
|
+
configure_logging_for_application(
|
|
88
|
+
debug=debug,
|
|
89
|
+
verbose=verbose,
|
|
90
|
+
log_to_file=log_file is not None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
logger.debug("Executing command: %s", " ".join(sys.argv))
|
|
94
|
+
|
|
95
|
+
if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
|
|
96
|
+
typer.echo(ctx.get_help())
|
|
97
|
+
raise typer.Exit()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
add_typer_helptree(
|
|
101
|
+
app=app,
|
|
102
|
+
console=console_stderr,
|
|
103
|
+
version=__version__,
|
|
104
|
+
hidden=False,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@app.command(name="gui")
|
|
108
|
+
def gui_command(
|
|
109
|
+
auto_close: int = typer.Option(0,
|
|
110
|
+
"--auto-close", "-c",
|
|
111
|
+
help = "Delay in milliseconds after which the GUI window will close (for automated testing). Use 0 to disable auto-closing.",
|
|
112
|
+
min=0)
|
|
113
|
+
)->None:
|
|
114
|
+
"""
|
|
115
|
+
Launch tkinter-based GUI.
|
|
116
|
+
"""
|
|
117
|
+
assured_auto_close_value = 0
|
|
118
|
+
|
|
119
|
+
# --- Helper, consistent gui failure message. ---
|
|
120
|
+
def _gui_failure_msg():
|
|
121
|
+
console_stderr.print("[bold red]GUI failed to launch[/bold red]")
|
|
122
|
+
console_stderr.print("Use CLI instead.")
|
|
123
|
+
console_stderr.print(f"pyhabitat.tkinter_is_available() = {pyhabitat.tkinter_is_available()}")
|
|
124
|
+
console_stderr.print(f"pyhabitat.on_termux() = {pyhabitat.on_termux()}")
|
|
125
|
+
|
|
126
|
+
if isinstance(auto_close, OptionInfo):
|
|
127
|
+
# Case 1: Called implicitly from main() (with no args)
|
|
128
|
+
# We received the metadata object, so use the function's default value (0).
|
|
129
|
+
# We don't need to do anything here since final_auto_close_value is already 0.
|
|
130
|
+
pass
|
|
131
|
+
else:
|
|
132
|
+
# Case 2: Called explicitly by Typer (gui -c 3000)
|
|
133
|
+
# Typer has successfully converted the command line argument, and auto_close is an int.
|
|
134
|
+
assured_auto_close_value = int(auto_close)
|
|
135
|
+
|
|
136
|
+
if not pyhabitat.tkinter_is_available():
|
|
137
|
+
_gui_failure_msg()
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
from .gui import start_gui
|
|
141
|
+
start_gui(time_auto_close = assured_auto_close_value)
|
|
142
|
+
|
|
143
|
+
@app.command(name="placeholder")
|
|
144
|
+
def placeholder(
|
|
145
|
+
path: Path = Path("path"),
|
|
146
|
+
):
|
|
147
|
+
"""Placeholder."""
|
|
148
|
+
console_stderr.print(f"{path=}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
app()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# src/blindwindow/context.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
7
|
+
SRC_DIR = PACKAGE_DIR.parent
|
|
8
|
+
PROJECT_ROOT = SRC_DIR.parent
|
|
9
|
+
|
|
10
|
+
APP_NAME = "blindwindow"
|
|
11
|
+
APP_NAME_PRETTY = "Blindwindow"
|
|
12
|
+
IMPORT_NAME = "blindwindow"
|
|
13
|
+
SRC_FOLDER_NAME = "blindwindow"
|
|
14
|
+
DESCRIPTION_STR = "A Python application."
|
|
15
|
+
APP_DIR = Path.home() / ".blindwindow"
|
|
16
|
+
LOG_FILE_PATH = APP_DIR / "blindwindow.log"
|
|
17
|
+
SERVICE = APP_NAME
|
|
18
|
+
CONFIG_PATH = APP_DIR / "config.json"
|
|
19
|
+
SECRET_PATH = APP_DIR / "vault.db"
|
|
20
|
+
ENV_PATH = PROJECT_ROOT / ".env"
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
|
|
2
|
+
#!/usr/bin/env python3
|
|
3
|
+
# src/blindwindow/gui.py
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ctypes
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from tkinter import messagebox, ttk
|
|
13
|
+
from maxson_gui_utils.tk_utils import center_window_on_primary
|
|
14
|
+
from maxson_gui_utils.external_web_launch import launch_configured_website
|
|
15
|
+
|
|
16
|
+
import pyhabitat
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .context import APP_NAME, IMPORT_NAME, CONFIG_PATH
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
APP_WIDTH = 800
|
|
24
|
+
APP_HEIGHT = 600
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GuiApp:
|
|
28
|
+
"""Main application window."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, root: tk.Tk):
|
|
31
|
+
self.root = root
|
|
32
|
+
|
|
33
|
+
#self._initialize_theme()
|
|
34
|
+
self._configure_window()
|
|
35
|
+
self._create_menubar()
|
|
36
|
+
self._create_widgets()
|
|
37
|
+
|
|
38
|
+
def _initialize_theme(self) -> None:
|
|
39
|
+
"""Initialize the application theme."""
|
|
40
|
+
|
|
41
|
+
theme_dir = files(
|
|
42
|
+
f"{IMPORT_NAME}.data.themes.forest"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self.root.tk.call(
|
|
46
|
+
"source",
|
|
47
|
+
str(theme_dir / "forest-light.tcl"),
|
|
48
|
+
)
|
|
49
|
+
self.root.tk.call(
|
|
50
|
+
"source",
|
|
51
|
+
str(theme_dir / "forest-dark.tcl"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
style = ttk.Style(self.root)
|
|
55
|
+
style.configure(".", padding=2)
|
|
56
|
+
style.configure("TFrame", padding=2)
|
|
57
|
+
style.configure("TLabelFrame", padding=(4, 2))
|
|
58
|
+
style.configure("TButton", padding=4)
|
|
59
|
+
style.configure("TCheckbutton", padding=2)
|
|
60
|
+
style.configure("TRadiobutton", padding=2)
|
|
61
|
+
|
|
62
|
+
style.theme_use("forest-dark")
|
|
63
|
+
|
|
64
|
+
def _configure_window(self) -> None:
|
|
65
|
+
self.root.title(f"{APP_NAME} v{__version__}")
|
|
66
|
+
self.root.geometry(
|
|
67
|
+
f"{APP_WIDTH}x{APP_HEIGHT}"
|
|
68
|
+
)
|
|
69
|
+
self.root.minsize(600, 400)
|
|
70
|
+
|
|
71
|
+
self._set_icon()
|
|
72
|
+
|
|
73
|
+
def _set_icon(self) -> None:
|
|
74
|
+
"""Set the application icon when available."""
|
|
75
|
+
try:
|
|
76
|
+
icon_path = files(
|
|
77
|
+
f"{IMPORT_NAME}.data.icons"
|
|
78
|
+
) / "water-green_256x256.png"
|
|
79
|
+
|
|
80
|
+
if icon_path.is_file():
|
|
81
|
+
self.icon_img = tk.PhotoImage(
|
|
82
|
+
file=str(icon_path)
|
|
83
|
+
)
|
|
84
|
+
self.root.iconphoto(
|
|
85
|
+
True,
|
|
86
|
+
self.icon_img,
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.debug(
|
|
90
|
+
"Unable to load application icon",
|
|
91
|
+
exc_info=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _create_menubar(self) -> None:
|
|
95
|
+
"""Create the application menu bar."""
|
|
96
|
+
|
|
97
|
+
menubar = tk.Menu(self.root)
|
|
98
|
+
self.root.configure(menu=menubar)
|
|
99
|
+
|
|
100
|
+
options = tk.Menu(
|
|
101
|
+
menubar,
|
|
102
|
+
tearoff=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
menubar.add_cascade(
|
|
106
|
+
label="Options",
|
|
107
|
+
menu=options,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
options.add_command(
|
|
111
|
+
label="About",
|
|
112
|
+
command=self._show_about,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
tools_menu = tk.Menu(menubar, tearoff=0)
|
|
116
|
+
menubar.add_cascade(label="Tools", menu=tools_menu)
|
|
117
|
+
|
|
118
|
+
tools_menu.add_command(label="Show Filled Files ", command=lambda: self._show_target_files_in_system_explorer())
|
|
119
|
+
tools_menu.add_command(label="Launch Configured Website ", command=lambda: self._launch_configured_website())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
options.add_separator()
|
|
123
|
+
|
|
124
|
+
options.add_command(
|
|
125
|
+
label="Exit",
|
|
126
|
+
command=self.root.destroy,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _create_widgets(self) -> None:
|
|
130
|
+
"""Create application widgets."""
|
|
131
|
+
|
|
132
|
+
frame = ttk.Frame(
|
|
133
|
+
self.root,
|
|
134
|
+
padding=12,
|
|
135
|
+
)
|
|
136
|
+
frame.pack(
|
|
137
|
+
fill="both",
|
|
138
|
+
expand=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
label = ttk.Label(
|
|
142
|
+
frame,
|
|
143
|
+
text=f"{APP_NAME} v{__version__}",
|
|
144
|
+
)
|
|
145
|
+
label.pack()
|
|
146
|
+
|
|
147
|
+
def _show_about(self) -> None:
|
|
148
|
+
messagebox.showinfo(
|
|
149
|
+
"About",
|
|
150
|
+
f"{APP_NAME} Version {__version__}",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _show_target_files_in_system_explorer(self) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Opens the system file explorer to the directory containing
|
|
156
|
+
the exported files, with GUI error handling.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
target_dir = APP_DIR
|
|
160
|
+
pyhabitat.show_system_explorer(path = target_dir)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
# The GUI catches the error to show a user-friendly popup
|
|
163
|
+
messagebox.showerror("Error", f"Could not open system explorer: {e}")
|
|
164
|
+
|
|
165
|
+
def _launch_configured_website(self):
|
|
166
|
+
launch_configured_website(path = CONFIG_PATH)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def apply_windows_taskbar_icon() -> None:
|
|
170
|
+
"""Set a stable Windows AppUserModelID."""
|
|
171
|
+
|
|
172
|
+
if not pyhabitat.on_windows():
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
app_id = (
|
|
177
|
+
f"CityOfMemphisWastewater."
|
|
178
|
+
f"{APP_NAME}.Application"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
|
182
|
+
app_id
|
|
183
|
+
)
|
|
184
|
+
except Exception:
|
|
185
|
+
logger.debug(
|
|
186
|
+
"Unable to set Windows taskbar identity",
|
|
187
|
+
exc_info=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def start_gui(time_auto_close=None) -> None:
|
|
192
|
+
"""Start the graphical application."""
|
|
193
|
+
|
|
194
|
+
apply_windows_taskbar_icon()
|
|
195
|
+
|
|
196
|
+
root = tk.Tk()
|
|
197
|
+
root.withdraw()
|
|
198
|
+
|
|
199
|
+
logger.debug("Starting %s", APP_NAME)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
app = GuiApp(root)
|
|
203
|
+
except Exception:
|
|
204
|
+
logger.exception("GUI startup failed")
|
|
205
|
+
root.destroy()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
root.deiconify()
|
|
209
|
+
root.mainloop()
|
|
210
|
+
|
|
211
|
+
logger.debug("%s: GUI closed", APP_NAME)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
start_gui()
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# src/blindwindow/logging_setup.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from logging.handlers import RotatingFileHandler
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .context import APP_NAME, LOG_FILE_PATH
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# -----
|
|
15
|
+
# Constants
|
|
16
|
+
# -----
|
|
17
|
+
|
|
18
|
+
FILE_LOG_LEVEL = logging.DEBUG
|
|
19
|
+
FILE_LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
20
|
+
FILE_LOG_BACKUP_COUNT = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# -----
|
|
24
|
+
# Logger Access
|
|
25
|
+
# -----
|
|
26
|
+
|
|
27
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
28
|
+
"""Return the package logger or a sub-logger under its namespace."""
|
|
29
|
+
target_name = name or "blindwindow"
|
|
30
|
+
return logging.getLogger(target_name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# -----
|
|
34
|
+
# Formatters
|
|
35
|
+
# -----
|
|
36
|
+
|
|
37
|
+
def _file_formatter() -> logging.Formatter:
|
|
38
|
+
"""Return the formatter used for persistent file logging."""
|
|
39
|
+
return logging.Formatter(
|
|
40
|
+
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
41
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _console_formatter(
|
|
46
|
+
debug: bool = False,
|
|
47
|
+
verbose: bool = False,
|
|
48
|
+
) -> logging.Formatter:
|
|
49
|
+
"""Return the formatter used for console output."""
|
|
50
|
+
if debug:
|
|
51
|
+
fmt = "%(levelname)-7s %(message)s"
|
|
52
|
+
elif verbose:
|
|
53
|
+
fmt = "%(message)s"
|
|
54
|
+
else:
|
|
55
|
+
fmt = "%(levelname)s: %(message)s"
|
|
56
|
+
|
|
57
|
+
return logging.Formatter(fmt)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# -----
|
|
61
|
+
# Handlers
|
|
62
|
+
# -----
|
|
63
|
+
|
|
64
|
+
def setup_file_logging() -> logging.Handler | None:
|
|
65
|
+
"""Create the persistent application log handler safely."""
|
|
66
|
+
if LOG_FILE_PATH is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
log_path = Path(LOG_FILE_PATH)
|
|
71
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
handler = RotatingFileHandler(
|
|
74
|
+
log_path,
|
|
75
|
+
maxBytes=FILE_LOG_MAX_BYTES,
|
|
76
|
+
backupCount=FILE_LOG_BACKUP_COUNT,
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
)
|
|
79
|
+
handler.setLevel(FILE_LOG_LEVEL)
|
|
80
|
+
handler.setFormatter(_file_formatter())
|
|
81
|
+
return handler
|
|
82
|
+
except (OSError, PermissionError) as err:
|
|
83
|
+
sys.stderr.write(f"Warning: Failed to initialize file log at {LOG_FILE_PATH}: {err}\n")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -----
|
|
88
|
+
# Configurations
|
|
89
|
+
# -----
|
|
90
|
+
|
|
91
|
+
def configure_logging_for_application(
|
|
92
|
+
debug: bool = False,
|
|
93
|
+
verbose: bool = False,
|
|
94
|
+
log_to_file: bool = True,
|
|
95
|
+
) -> logging.Logger:
|
|
96
|
+
"""Configure logging for an executable application entrypoint."""
|
|
97
|
+
logger = get_logger()
|
|
98
|
+
|
|
99
|
+
logger.setLevel(logging.DEBUG)
|
|
100
|
+
logger.propagate = False
|
|
101
|
+
|
|
102
|
+
if logger.hasHandlers():
|
|
103
|
+
logger.handlers.clear()
|
|
104
|
+
|
|
105
|
+
# File Logging
|
|
106
|
+
if log_to_file:
|
|
107
|
+
file_handler = setup_file_logging()
|
|
108
|
+
if file_handler is not None:
|
|
109
|
+
logger.addHandler(file_handler)
|
|
110
|
+
|
|
111
|
+
# Console Logging
|
|
112
|
+
if debug:
|
|
113
|
+
console_level = logging.DEBUG
|
|
114
|
+
elif verbose:
|
|
115
|
+
console_level = logging.INFO
|
|
116
|
+
else:
|
|
117
|
+
console_level = logging.WARNING
|
|
118
|
+
|
|
119
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
120
|
+
console_handler.setLevel(console_level)
|
|
121
|
+
console_handler.setFormatter(
|
|
122
|
+
_console_formatter(debug=debug, verbose=verbose)
|
|
123
|
+
)
|
|
124
|
+
logger.addHandler(console_handler)
|
|
125
|
+
|
|
126
|
+
logger.debug("Application logging configured for %s.", APP_NAME)
|
|
127
|
+
return logger
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def configure_logging_for_library(
|
|
131
|
+
debug: bool = False,
|
|
132
|
+
verbose: bool = False,
|
|
133
|
+
) -> logging.Logger:
|
|
134
|
+
"""Configure logging when this package is consumed as a library module."""
|
|
135
|
+
logger = get_logger()
|
|
136
|
+
|
|
137
|
+
if debug:
|
|
138
|
+
level = logging.DEBUG
|
|
139
|
+
elif verbose:
|
|
140
|
+
level = logging.INFO
|
|
141
|
+
else:
|
|
142
|
+
level = logging.WARNING
|
|
143
|
+
|
|
144
|
+
logger.setLevel(level)
|
|
145
|
+
logger.propagate = True
|
|
146
|
+
|
|
147
|
+
if not logger.handlers:
|
|
148
|
+
logger.addHandler(logging.NullHandler())
|
|
149
|
+
|
|
150
|
+
return logger
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def configure_logging_all_debug() -> None:
|
|
154
|
+
"""Force DEBUG level logging globally across the root logger and third-party tools."""
|
|
155
|
+
root_logger = logging.getLogger()
|
|
156
|
+
root_logger.setLevel(logging.DEBUG)
|
|
157
|
+
|
|
158
|
+
if root_logger.hasHandlers():
|
|
159
|
+
root_logger.handlers.clear()
|
|
160
|
+
|
|
161
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
162
|
+
console_handler.setLevel(logging.DEBUG)
|
|
163
|
+
console_handler.setFormatter(
|
|
164
|
+
logging.Formatter("%(asctime)s - %(levelname)-7s - [%(name)s] %(message)s")
|
|
165
|
+
)
|
|
166
|
+
root_logger.addHandler(console_handler)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# -----
|
|
170
|
+
# Diagnostics
|
|
171
|
+
# -----
|
|
172
|
+
|
|
173
|
+
def log_traceback(logger_instance: logging.Logger | None = None) -> None:
|
|
174
|
+
"""Safely print stack traces to stderr if debug level is active."""
|
|
175
|
+
target = logger_instance or get_logger()
|
|
176
|
+
if target.isEnabledFor(logging.DEBUG):
|
|
177
|
+
traceback.print_exc(file=sys.stderr)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blindwindow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Route console stderr and stdout to blindwindow, to either duplicate or display what is otherwise hidden. Namely for MSIX deployed CLI's
|
|
5
|
+
Author-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
6
|
+
Maintainer-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Classifier: Development Status :: 3 - Alpha
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: dworshak-config>=0.2.8
|
|
22
|
+
Requires-Dist: pyhabitat>=1.3.9
|
|
23
|
+
Requires-Dist: typer>=0.27.0
|
|
24
|
+
Requires-Dist: typer-helptree>=0.2.12
|
|
25
|
+
Requires-Dist: maxson-gui-utils>=0.1.5
|
|
26
|
+
Provides-Extra: gui
|
|
27
|
+
Requires-Dist: maxson_gui_utils>=0.1.4; extra == "gui"
|
|
28
|
+
|
|
29
|
+
# Blindwindow
|
|
30
|
+
|
|
31
|
+
Render console stderr and stdout to a TKinter based REPL widget masquerading as a log pange.
|
|
32
|
+
|
|
33
|
+
Use Case: When calling a CLI packaged inside of a MSIX that has been delivered via the Microsoft Store. This use case typically does not allow prints.
|
|
34
|
+
Example: `pdflinkcheck.exe serve --help`
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Helptree
|
|
39
|
+
|
|
40
|
+
See the `blindwindow` Typer CLI structure.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
blindwindow helptree
|
|
44
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/blindwindow/VERSION
|
|
5
|
+
src/blindwindow/__init__.py
|
|
6
|
+
src/blindwindow/__main__.py
|
|
7
|
+
src/blindwindow/_version.py
|
|
8
|
+
src/blindwindow/cli.py
|
|
9
|
+
src/blindwindow/config.py
|
|
10
|
+
src/blindwindow/context.py
|
|
11
|
+
src/blindwindow/core.py
|
|
12
|
+
src/blindwindow/gui.py
|
|
13
|
+
src/blindwindow/helpers.py
|
|
14
|
+
src/blindwindow/logging_setup.py
|
|
15
|
+
src/blindwindow.egg-info/PKG-INFO
|
|
16
|
+
src/blindwindow.egg-info/SOURCES.txt
|
|
17
|
+
src/blindwindow.egg-info/dependency_links.txt
|
|
18
|
+
src/blindwindow.egg-info/entry_points.txt
|
|
19
|
+
src/blindwindow.egg-info/requires.txt
|
|
20
|
+
src/blindwindow.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blindwindow
|