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 ADDED
@@ -0,0 +1,3 @@
1
+ """LaunchLine: a lightweight Windows Terminal launcher for interactive CLI tools."""
2
+
3
+ from __future__ import annotations
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
@@ -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