launchline 0.1.0__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.
- launchline/__init__.py +3 -0
- launchline/__main__.py +104 -0
- launchline/_version.py +24 -0
- launchline/assets/launchline.ico +0 -0
- launchline/cli.py +70 -0
- launchline/config.py +257 -0
- launchline/exceptions.py +39 -0
- launchline/fuzzy.py +59 -0
- launchline/keys.py +319 -0
- launchline/runner.py +100 -0
- launchline/ui.py +699 -0
- launchline-0.1.0.dist-info/METADATA +215 -0
- launchline-0.1.0.dist-info/RECORD +16 -0
- launchline-0.1.0.dist-info/WHEEL +4 -0
- launchline-0.1.0.dist-info/entry_points.txt +2 -0
- launchline-0.1.0.dist-info/licenses/LICENSE +21 -0
launchline/__init__.py
ADDED
launchline/__main__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Application entry point for the LaunchLine launcher.
|
|
2
|
+
|
|
3
|
+
When executed as ``python -m launchline``, the :func:`main` function
|
|
4
|
+
parses CLI arguments, configures logging, and delegates to
|
|
5
|
+
:class:`Application` which runs the interactive launcher loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from launchline.config import ConfigLoader, LaunchLineConfig
|
|
13
|
+
from launchline.exceptions import LaunchError, LaunchLineError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Application:
|
|
17
|
+
"""Top-level application controller for the launcher loop.
|
|
18
|
+
|
|
19
|
+
Owns the UI and runner instances and repeatedly presents the
|
|
20
|
+
launcher menu until the user exits or ``on_exit`` is ``"exit"``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: LaunchLineConfig) -> None:
|
|
24
|
+
"""Initialise the application with a validated configuration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: The loaded and validated launcher configuration.
|
|
28
|
+
"""
|
|
29
|
+
self._config = config
|
|
30
|
+
|
|
31
|
+
def run(self) -> None:
|
|
32
|
+
"""Run the interactive launcher loop.
|
|
33
|
+
|
|
34
|
+
Presents the TUI, launches the selected entry, and either
|
|
35
|
+
re-displays the menu (``on_exit="restart"``) or terminates
|
|
36
|
+
(``on_exit="exit"``).
|
|
37
|
+
"""
|
|
38
|
+
from launchline.ui import LaunchLineUI
|
|
39
|
+
|
|
40
|
+
ui = LaunchLineUI(self._config)
|
|
41
|
+
runner = None
|
|
42
|
+
|
|
43
|
+
while True:
|
|
44
|
+
selected = ui.run()
|
|
45
|
+
if selected is None:
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
if runner is None:
|
|
49
|
+
from launchline.runner import EntryRunner
|
|
50
|
+
|
|
51
|
+
runner = EntryRunner(self._config)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
exit_code = runner.launch(selected)
|
|
55
|
+
except LaunchError as exc:
|
|
56
|
+
print(f"\nError: {exc}", file=sys.stderr)
|
|
57
|
+
if self._config.on_exit == "exit":
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
input("Press Enter to return to the launcher...")
|
|
60
|
+
continue
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
if self._config.on_exit == "exit":
|
|
63
|
+
sys.exit(130)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if self._config.on_exit == "exit":
|
|
67
|
+
sys.exit(exit_code)
|
|
68
|
+
# on_exit == "restart": loop continues
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
"""Parse arguments, configure logging, and run the launcher."""
|
|
73
|
+
import logging
|
|
74
|
+
|
|
75
|
+
from launchline.cli import CommandLineInterface
|
|
76
|
+
|
|
77
|
+
args = CommandLineInterface.parse_args()
|
|
78
|
+
|
|
79
|
+
if args.icon_path:
|
|
80
|
+
print(CommandLineInterface.icon_path())
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
logging.basicConfig(
|
|
84
|
+
level=args.log_level,
|
|
85
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
86
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
config_existed = args.config is not None or ConfigLoader.DEFAULT_PATH.exists()
|
|
91
|
+
config_path = ConfigLoader.resolve_path(args.config)
|
|
92
|
+
if not config_existed:
|
|
93
|
+
print(f"Created starter config at {config_path}")
|
|
94
|
+
print("Edit it to add your tools, then run launchline again.\n")
|
|
95
|
+
config = ConfigLoader.load(config_path)
|
|
96
|
+
except LaunchLineError as exc:
|
|
97
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
Application(config).run()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
launchline/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
Binary file
|
launchline/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Command-line interface definition.
|
|
2
|
+
|
|
3
|
+
Provides :class:`CommandLineInterface` which encapsulates the
|
|
4
|
+
``argparse`` setup and validation for the ``launchline`` CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import logging
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommandLineInterface:
|
|
16
|
+
"""Command-line argument parser for LaunchLine.
|
|
17
|
+
|
|
18
|
+
All methods are static; the class serves as a logical namespace.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def icon_path() -> Path:
|
|
23
|
+
"""Return the path to the bundled LaunchLine icon.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Absolute path to the ``.ico`` file shipped with the package.
|
|
27
|
+
"""
|
|
28
|
+
resource = files("launchline").joinpath("assets", "launchline.ico")
|
|
29
|
+
return Path(str(resource))
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
33
|
+
"""Parse and validate command-line arguments.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
argv: Argument list to parse. Defaults to ``sys.argv[1:]``
|
|
37
|
+
when ``None``.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Parsed argument namespace with ``config`` (``Path | None``)
|
|
41
|
+
and ``log_level`` (``int``) attributes.
|
|
42
|
+
"""
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="launchline",
|
|
45
|
+
description="A lightweight launcher for interactive CLI tools.",
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--config",
|
|
50
|
+
type=Path,
|
|
51
|
+
default=None,
|
|
52
|
+
help="Path to config TOML (overrides env var and default path).",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--log-level",
|
|
56
|
+
type=str,
|
|
57
|
+
default="WARNING",
|
|
58
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
59
|
+
help="Logging verbosity (default: %(default)s).",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--icon-path",
|
|
63
|
+
action="store_true",
|
|
64
|
+
default=False,
|
|
65
|
+
help="Print the path to the bundled icon and exit.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
args.log_level = getattr(logging, args.log_level)
|
|
70
|
+
return args
|
launchline/config.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""TOML configuration loading and validation.
|
|
2
|
+
|
|
3
|
+
This module defines the configuration data model
|
|
4
|
+
(:class:`EntryConfig`, :class:`LaunchLineConfig`) and provides
|
|
5
|
+
:class:`ConfigLoader` for resolving, loading, validating, and
|
|
6
|
+
bootstrapping TOML configuration files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import tomllib
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from launchline.exceptions import ConfigurationError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class EntryConfig:
|
|
24
|
+
"""Configuration for a single launchable entry.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
name: Display name shown in the launcher menu.
|
|
28
|
+
command: Executable command to run (e.g. ``"pwsh"``).
|
|
29
|
+
args: Additional command-line arguments passed to the command.
|
|
30
|
+
description: Short description shown beside the entry name.
|
|
31
|
+
working_directory: Working directory for the subprocess, or
|
|
32
|
+
``None`` to inherit the launcher's CWD.
|
|
33
|
+
env: Extra environment variables merged into the subprocess
|
|
34
|
+
environment.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
command: str
|
|
39
|
+
args: tuple[str, ...] = ()
|
|
40
|
+
description: str = ""
|
|
41
|
+
working_directory: Path | None = None
|
|
42
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class LaunchLineConfig:
|
|
47
|
+
"""Top-level launcher configuration.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
entries: Ordered tuple of launchable entries.
|
|
51
|
+
title: Text displayed in the header and terminal title bar.
|
|
52
|
+
on_exit: Behaviour after a launched process exits —
|
|
53
|
+
``"restart"`` (re-show the menu) or ``"exit"`` (quit).
|
|
54
|
+
show_exit: Whether to display the ``Exit`` option (shortcut
|
|
55
|
+
``0``) in the menu.
|
|
56
|
+
clear_on_launch: Whether to clear the screen before launching.
|
|
57
|
+
ghost_text: Whether to show an autocomplete hint on the prompt
|
|
58
|
+
line with the highlighted entry's name.
|
|
59
|
+
numeric_trigger: Whether pressing a digit immediately launches
|
|
60
|
+
the matching entry (when ≤9 entries and no active query).
|
|
61
|
+
When ``False``, digits always enter the search query and
|
|
62
|
+
fuzzy-match against both numbers and names.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
entries: tuple[EntryConfig, ...]
|
|
66
|
+
title: str = "LaunchLine"
|
|
67
|
+
on_exit: str = "restart"
|
|
68
|
+
show_exit: bool = True
|
|
69
|
+
clear_on_launch: bool = True
|
|
70
|
+
ghost_text: bool = True
|
|
71
|
+
numeric_trigger: bool = True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConfigLoader:
|
|
75
|
+
"""Resolves, loads, validates, and bootstraps TOML config files.
|
|
76
|
+
|
|
77
|
+
All methods are static; the class serves as a logical namespace.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
DEFAULT_PATH: Path = Path.home() / ".config" / "launchline" / "config.toml"
|
|
81
|
+
"""Default file-system location for the configuration file."""
|
|
82
|
+
|
|
83
|
+
ENV_VAR_NAME: str = "LAUNCHLINE_CONFIG"
|
|
84
|
+
"""Environment variable that overrides the default config path."""
|
|
85
|
+
|
|
86
|
+
VALID_ON_EXIT: tuple[str, ...] = ("restart", "exit")
|
|
87
|
+
"""Permitted values for the ``on_exit`` setting."""
|
|
88
|
+
|
|
89
|
+
STARTER_CONFIG: str = """\
|
|
90
|
+
# LaunchLine configuration
|
|
91
|
+
# See https://github.com/mikejhill/launchline#configuration-reference
|
|
92
|
+
|
|
93
|
+
[settings]
|
|
94
|
+
# title = "LaunchLine"
|
|
95
|
+
# on_exit = "restart" # "restart" (default) or "exit"
|
|
96
|
+
# show_exit = true
|
|
97
|
+
# clear_on_launch = true
|
|
98
|
+
# ghost_text = true
|
|
99
|
+
# numeric_trigger = true
|
|
100
|
+
|
|
101
|
+
[[entries]]
|
|
102
|
+
name = "PowerShell"
|
|
103
|
+
command = "pwsh"
|
|
104
|
+
description = "PowerShell 7"
|
|
105
|
+
"""
|
|
106
|
+
"""Template written when no config file exists yet."""
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def resolve_path(cli_path: Path | None = None) -> Path:
|
|
110
|
+
"""Determine the config file path using the resolution order.
|
|
111
|
+
|
|
112
|
+
Priority: CLI flag → environment variable → default path.
|
|
113
|
+
If no config file exists at any location, a starter config is
|
|
114
|
+
created automatically at :attr:`DEFAULT_PATH`.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
cli_path: Explicit path from ``--config`` flag, or ``None``.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Resolved path to an existing config file.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ConfigurationError: If a specified path does not exist.
|
|
124
|
+
"""
|
|
125
|
+
if cli_path is not None:
|
|
126
|
+
expanded = cli_path.expanduser()
|
|
127
|
+
if not expanded.exists():
|
|
128
|
+
raise ConfigurationError(f"Config file not found: {expanded}")
|
|
129
|
+
return expanded
|
|
130
|
+
|
|
131
|
+
env_path = os.environ.get(ConfigLoader.ENV_VAR_NAME)
|
|
132
|
+
if env_path:
|
|
133
|
+
expanded = Path(env_path).expanduser()
|
|
134
|
+
if expanded.exists():
|
|
135
|
+
return expanded
|
|
136
|
+
raise ConfigurationError(
|
|
137
|
+
f"Config file from {ConfigLoader.ENV_VAR_NAME} not found: {expanded}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if ConfigLoader.DEFAULT_PATH.exists():
|
|
141
|
+
return ConfigLoader.DEFAULT_PATH
|
|
142
|
+
|
|
143
|
+
return ConfigLoader._create_default()
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def load(config_path: Path) -> LaunchLineConfig:
|
|
147
|
+
"""Load and validate a TOML config file.
|
|
148
|
+
|
|
149
|
+
Reads the file, validates all settings and entries, and returns
|
|
150
|
+
a fully populated :class:`LaunchLineConfig`.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
config_path: Path to the TOML file.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A validated ``LaunchLineConfig`` instance.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ConfigurationError: If the file contains invalid TOML,
|
|
160
|
+
unknown setting values, or malformed entries.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
with config_path.open("rb") as f:
|
|
164
|
+
raw = tomllib.load(f)
|
|
165
|
+
except tomllib.TOMLDecodeError as exc:
|
|
166
|
+
raise ConfigurationError(f"Invalid TOML in {config_path}: {exc}") from exc
|
|
167
|
+
|
|
168
|
+
settings = raw.get("settings", {})
|
|
169
|
+
|
|
170
|
+
title: str = settings.get("title", "LaunchLine")
|
|
171
|
+
|
|
172
|
+
on_exit = settings.get("on_exit", "restart")
|
|
173
|
+
if on_exit not in ConfigLoader.VALID_ON_EXIT:
|
|
174
|
+
raise ConfigurationError(
|
|
175
|
+
f"Invalid on_exit value: '{on_exit}'. "
|
|
176
|
+
f"Must be one of: {', '.join(ConfigLoader.VALID_ON_EXIT)}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
show_exit: bool = settings.get("show_exit", True)
|
|
180
|
+
clear_on_launch: bool = settings.get("clear_on_launch", True)
|
|
181
|
+
ghost_text: bool = settings.get("ghost_text", True)
|
|
182
|
+
numeric_trigger: bool = settings.get("numeric_trigger", True)
|
|
183
|
+
|
|
184
|
+
raw_entries: list[dict[str, object]] = raw.get("entries", [])
|
|
185
|
+
if not raw_entries:
|
|
186
|
+
raise ConfigurationError("No entries configured.")
|
|
187
|
+
|
|
188
|
+
entries: list[EntryConfig] = []
|
|
189
|
+
for i, entry_raw in enumerate(raw_entries, 1):
|
|
190
|
+
name = entry_raw.get("name", "")
|
|
191
|
+
command = entry_raw.get("command", "")
|
|
192
|
+
if not name or not command:
|
|
193
|
+
raise ConfigurationError(
|
|
194
|
+
f"Entry {i}: 'name' and 'command' are required."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
raw_args = entry_raw.get("args", [])
|
|
198
|
+
if not isinstance(raw_args, list):
|
|
199
|
+
raise ConfigurationError(
|
|
200
|
+
f"Entry {i}: 'args' must be a list of strings."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
wd_raw = entry_raw.get("working_directory")
|
|
204
|
+
wd: Path | None = None
|
|
205
|
+
if wd_raw:
|
|
206
|
+
wd = Path(str(wd_raw)).expanduser()
|
|
207
|
+
if not wd.exists():
|
|
208
|
+
logger.warning(
|
|
209
|
+
"Entry '%s': working_directory does not exist: %s",
|
|
210
|
+
name,
|
|
211
|
+
wd,
|
|
212
|
+
)
|
|
213
|
+
wd = None
|
|
214
|
+
|
|
215
|
+
raw_env = entry_raw.get("env", {})
|
|
216
|
+
if not isinstance(raw_env, dict):
|
|
217
|
+
raise ConfigurationError(
|
|
218
|
+
f"Entry {i}: 'env' must be a table of strings."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
entries.append(
|
|
222
|
+
EntryConfig(
|
|
223
|
+
name=str(name),
|
|
224
|
+
command=str(command),
|
|
225
|
+
args=tuple(str(a) for a in raw_args),
|
|
226
|
+
description=str(entry_raw.get("description", "")),
|
|
227
|
+
working_directory=wd,
|
|
228
|
+
env={str(k): str(v) for k, v in raw_env.items()},
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return LaunchLineConfig(
|
|
233
|
+
entries=tuple(entries),
|
|
234
|
+
title=title,
|
|
235
|
+
on_exit=on_exit,
|
|
236
|
+
show_exit=show_exit,
|
|
237
|
+
clear_on_launch=clear_on_launch,
|
|
238
|
+
ghost_text=ghost_text,
|
|
239
|
+
numeric_trigger=numeric_trigger,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _create_default() -> Path:
|
|
244
|
+
"""Create a starter config at :attr:`DEFAULT_PATH`.
|
|
245
|
+
|
|
246
|
+
Creates parent directories as needed and writes
|
|
247
|
+
:attr:`STARTER_CONFIG` to the file.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Path to the newly created config file.
|
|
251
|
+
"""
|
|
252
|
+
ConfigLoader.DEFAULT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
ConfigLoader.DEFAULT_PATH.write_text(
|
|
254
|
+
ConfigLoader.STARTER_CONFIG, encoding="utf-8"
|
|
255
|
+
)
|
|
256
|
+
logger.info("Created starter config at %s", ConfigLoader.DEFAULT_PATH)
|
|
257
|
+
return ConfigLoader.DEFAULT_PATH
|
launchline/exceptions.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Project-specific exception hierarchy.
|
|
2
|
+
|
|
3
|
+
All custom exceptions inherit from ``LaunchLineError`` so callers can
|
|
4
|
+
catch the entire family with a single ``except`` clause when needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LaunchLineError(Exception):
|
|
11
|
+
"""Base exception for all LaunchLine errors.
|
|
12
|
+
|
|
13
|
+
Subclassed by every domain-specific exception so that callers may
|
|
14
|
+
catch ``LaunchLineError`` as a generic fallback.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigurationError(LaunchLineError):
|
|
19
|
+
"""Raised when a configuration file is invalid, missing, or malformed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LaunchError(LaunchLineError):
|
|
23
|
+
"""Raised when a configured command fails to launch.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
command: The command string that failed.
|
|
27
|
+
reason: A human-readable explanation of the failure.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, command: str, reason: str) -> None:
|
|
31
|
+
"""Initialise with the failed command and a reason string.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
command: The command that could not be executed.
|
|
35
|
+
reason: Why the launch failed (e.g. file not found).
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(f"Failed to launch '{command}': {reason}")
|
|
38
|
+
self.command = command
|
|
39
|
+
self.reason = reason
|
launchline/fuzzy.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Fuzzy substring matching with scoring.
|
|
2
|
+
|
|
3
|
+
Provides :class:`FuzzyMatcher`, which scores how well a short query
|
|
4
|
+
string matches against a longer candidate string using an ordered,
|
|
5
|
+
non-contiguous character-matching algorithm with bonuses for runs and
|
|
6
|
+
word-boundary alignment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FuzzyMatcher:
|
|
13
|
+
"""Fuzzy substring matching with scoring.
|
|
14
|
+
|
|
15
|
+
All methods are static; the class serves as a logical namespace.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def score(query: str, candidate: str) -> int | None:
|
|
20
|
+
"""Score how well *query* fuzzy-matches *candidate*.
|
|
21
|
+
|
|
22
|
+
All characters in *query* must appear in *candidate* in order (not
|
|
23
|
+
necessarily contiguous). Higher scores indicate better matches, with
|
|
24
|
+
bonuses for contiguous runs and word-boundary alignment.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
query: The search string.
|
|
28
|
+
candidate: The string to match against.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
An integer score (higher is better), or ``None`` if the query does
|
|
32
|
+
not match the candidate.
|
|
33
|
+
"""
|
|
34
|
+
if not query:
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
query_lower = query.lower()
|
|
38
|
+
candidate_lower = candidate.lower()
|
|
39
|
+
|
|
40
|
+
q_idx = 0
|
|
41
|
+
score = 0
|
|
42
|
+
prev_match_idx = -2
|
|
43
|
+
|
|
44
|
+
for c_idx, c_char in enumerate(candidate_lower):
|
|
45
|
+
if q_idx < len(query_lower) and c_char == query_lower[q_idx]:
|
|
46
|
+
score += 1
|
|
47
|
+
# Contiguous character bonus
|
|
48
|
+
if c_idx == prev_match_idx + 1:
|
|
49
|
+
score += 2
|
|
50
|
+
# Word-boundary bonus
|
|
51
|
+
if c_idx == 0 or candidate_lower[c_idx - 1] in " -_./\\":
|
|
52
|
+
score += 3
|
|
53
|
+
prev_match_idx = c_idx
|
|
54
|
+
q_idx += 1
|
|
55
|
+
|
|
56
|
+
if q_idx < len(query_lower):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
return score
|