lus 0.4.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.
lus/LusFile.py ADDED
@@ -0,0 +1,519 @@
1
+ import os
2
+ import re
3
+ import shlex
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, List, Tuple
9
+
10
+ import expandvars
11
+ import kdl
12
+
13
+
14
+ @dataclass
15
+ class NormalizedNode:
16
+ name: str
17
+ args: List[Any]
18
+ properties: Dict[str, Any]
19
+ children: List["NormalizedNode"]
20
+
21
+
22
+ def _normalize_value(value):
23
+ if isinstance(value, float) and value.is_integer():
24
+ return int(value)
25
+ return value
26
+
27
+
28
+ def _normalize_node(node) -> NormalizedNode:
29
+ props = getattr(node, "properties", getattr(node, "props", {}))
30
+ children = getattr(node, "children", getattr(node, "nodes", []))
31
+ return NormalizedNode(
32
+ name=getattr(node, "name", ""),
33
+ args=[_normalize_value(arg) for arg in getattr(node, "args", [])],
34
+ properties={k: _normalize_value(v) for k, v in props.items()},
35
+ children=[_normalize_node(child) for child in children],
36
+ )
37
+
38
+
39
+ def _normalize_nodes(nodes) -> List[NormalizedNode]:
40
+ return [_normalize_node(node) for node in nodes]
41
+
42
+
43
+ _KDL_PATCHED = False
44
+
45
+
46
+ def _ensure_kdl_supports_bare_identifiers():
47
+ global _KDL_PATCHED
48
+ if _KDL_PATCHED:
49
+ return
50
+
51
+ from kdl import converters
52
+ from kdl import parsefuncs
53
+ from kdl.errors import ParseError, ParseFragment
54
+ from kdl.result import Failure, Result
55
+ from kdl import types as kdl_types
56
+
57
+ def parse_value_with_bare_identifiers(stream, start):
58
+ tag, i = parsefuncs.parseTag(stream, start)
59
+ if tag is Failure:
60
+ tag = None
61
+
62
+ value_start = i
63
+ val, i = parsefuncs.parseNumber(stream, i)
64
+ if val is Failure:
65
+ val, i = parsefuncs.parseKeyword(stream, i)
66
+ if val is Failure:
67
+ val, i = parsefuncs.parseString(stream, i)
68
+ if val is Failure:
69
+ ident, ident_end = parsefuncs.parseIdent(stream, i)
70
+ if ident is not Failure:
71
+ val = kdl_types.String(ident)
72
+ i = ident_end
73
+
74
+ if val is not Failure:
75
+ val.tag = tag
76
+ for key, converter in stream.config.valueConverters.items():
77
+ if val.matchesKey(key):
78
+ val = converter(
79
+ val,
80
+ ParseFragment(stream[value_start:i], stream, i),
81
+ )
82
+ if val == NotImplemented:
83
+ continue
84
+ else:
85
+ break
86
+ else:
87
+ if tag is None and stream.config.nativeUntaggedValues:
88
+ val = val.value
89
+ if tag is not None and stream.config.nativeTaggedValues:
90
+ val = converters.toNative(
91
+ val,
92
+ ParseFragment(stream[value_start:i], stream, i),
93
+ )
94
+ return Result((None, val), i)
95
+
96
+ if stream[i] == "'":
97
+ raise ParseError(stream, i, "KDL strings use double-quotes.")
98
+
99
+ ident, _ = parsefuncs.parseBareIdent(stream, i)
100
+ if ident is not Failure and ident.lower() in ("true", "false", "null"):
101
+ raise ParseError(stream, i, "KDL keywords are lower-case.")
102
+
103
+ if tag is not None:
104
+ raise ParseError(stream, i, "Found a tag, but no value following it.")
105
+ return Result.fail(start)
106
+
107
+ parsefuncs.parseValue = parse_value_with_bare_identifiers
108
+ _KDL_PATCHED = True
109
+
110
+
111
+ class Environment:
112
+ def __init__(self, variables: Dict[str, str]):
113
+ self.args_used = False
114
+ self.variables = variables
115
+ assert "args" in variables
116
+
117
+ def get(self, key: str, fallback: str = None) -> str:
118
+ if key in self.variables:
119
+ if key == "args":
120
+ self.args_used = True
121
+ return self.variables[key]
122
+ return os.environ.get(key, fallback)
123
+
124
+
125
+ class LusFile:
126
+ @staticmethod
127
+ def _strip_ansi(text: str) -> str:
128
+ # Remove ANSI escape codes for visible width calculation
129
+ return re.sub(r"\x1b\[[0-9;]*m", "", text)
130
+
131
+ def __init__(self, content: str, invocation_directory: str = None):
132
+ _ensure_kdl_supports_bare_identifiers()
133
+ self._raw_content = content
134
+ self.main_lus_kdl = _normalize_nodes(kdl.parse(content).nodes)
135
+ self.print_commands = True
136
+ self.local_variables = {}
137
+ self._piped = not sys.stdout.isatty()
138
+ self._old_working_directory = os.getcwd()
139
+ self._invocation_directory = invocation_directory or os.getcwd()
140
+ self._subcommand_comments = self._extract_top_level_comments(content)
141
+ self._aliases = self._compute_aliases(self.main_lus_kdl)
142
+
143
+ if self.main_lus_kdl:
144
+ self.check_args(self.main_lus_kdl, sys.argv[1:], True)
145
+
146
+ def _extract_top_level_comments(self, content: str) -> Dict[str, str]:
147
+ comments = {}
148
+ pending: List[str] = []
149
+ depth = 0
150
+
151
+ for line in content.splitlines():
152
+ stripped = line.strip()
153
+
154
+ if stripped.startswith("//"):
155
+ if depth == 0:
156
+ pending.append(stripped[2:].strip())
157
+ continue
158
+
159
+ if depth == 0 and stripped and not stripped.startswith(("{", "}")):
160
+ # Grab the token up to whitespace or '{'
161
+ token = re.split(r"\s|{", stripped, maxsplit=1)[0]
162
+ if token and pending:
163
+ comments[token] = " ".join(pending)
164
+ pending = []
165
+
166
+ depth += line.count("{") - line.count("}")
167
+
168
+ # Only keep pending comments for top-level declarations
169
+ if depth != 0:
170
+ pending = []
171
+
172
+ return comments
173
+
174
+ def _compute_aliases(self, nodes: List[NormalizedNode]) -> Dict[str, str]:
175
+ aliases: Dict[str, str] = {}
176
+ for node in nodes:
177
+ if node.name in ("", "$", "-"):
178
+ continue
179
+ if len(node.children) != 1:
180
+ continue
181
+ child = node.children[0]
182
+ if child.properties or node.properties:
183
+ continue
184
+ # Handle both "$ lus build" and "lus build" formats
185
+ if child.name in ("$", "-"):
186
+ args = child.args
187
+ if len(args) >= 2 and args[0] == "lus" and isinstance(args[1], str):
188
+ aliases[node.name] = args[1]
189
+ elif child.name == "lus" and len(child.children) == 0:
190
+ args = child.args
191
+ if len(args) >= 1 and isinstance(args[0], str):
192
+ aliases[node.name] = args[0]
193
+ return aliases
194
+
195
+ def print_command(self, args: List[str]):
196
+ if self.print_commands:
197
+ self._print(f"\x1b[1m{shlex.join(args)}\x1b[0m")
198
+
199
+ def _print(self, message: str):
200
+ if self._piped:
201
+ # strip ANSI escape codes
202
+ ansi_escape = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')
203
+ message = ansi_escape.sub('', message)
204
+ print(message, flush=True)
205
+
206
+ def run(self, args: List[str], properties: Dict[str, str]):
207
+ if "&&" in args or "||" in args:
208
+ return self._run_chained(args, properties)
209
+ status, _ = self._run_single(args, properties)
210
+ if status != 0:
211
+ raise SystemExit(status)
212
+
213
+ def _run_chained(self, args: List[str], properties: Dict[str, str]):
214
+ segments = []
215
+ operators = []
216
+ current = []
217
+
218
+ for arg in args:
219
+ if arg in ("&&", "||"):
220
+ if len(current) == 0:
221
+ raise SystemExit(1)
222
+ segments.append(current)
223
+ operators.append(arg)
224
+ current = []
225
+ else:
226
+ current.append(arg)
227
+
228
+ if len(current) == 0:
229
+ raise SystemExit(1)
230
+ segments.append(current)
231
+
232
+ last_status = 0
233
+
234
+ for i, segment in enumerate(segments):
235
+ try:
236
+ status, condition = self._run_single(segment, properties)
237
+ except SystemExit as e:
238
+ status = e.code
239
+ condition = status == 0
240
+ if len(segment) > 0 and segment[0] == "exit":
241
+ raise
242
+ except subprocess.CalledProcessError as e:
243
+ status = e.returncode
244
+ condition = False
245
+
246
+ last_status = status
247
+
248
+ if i < len(operators):
249
+ op = operators[i]
250
+ if op == "&&":
251
+ if condition:
252
+ continue
253
+ if status != 0:
254
+ raise SystemExit(status)
255
+ return
256
+ if op == "||":
257
+ if condition:
258
+ return
259
+ continue
260
+
261
+ if last_status != 0:
262
+ raise SystemExit(last_status)
263
+
264
+ def _run_single(
265
+ self, args: List[str], properties: Dict[str, str]
266
+ ) -> Tuple[int, bool]:
267
+ if args[0] == "exit":
268
+ code = args[1] if len(args) > 1 else 0
269
+ try:
270
+ code = int(code)
271
+ except (ValueError, TypeError):
272
+ pass
273
+ raise SystemExit(code)
274
+ elif args[0] == "cd":
275
+ self.print_command(args)
276
+ if len(args) == 2 and args[1] == "-":
277
+ os.chdir(self._old_working_directory)
278
+ return 0, True
279
+ self._old_working_directory = os.getcwd()
280
+ os.chdir(args[1])
281
+ return 0, True
282
+ elif args[0] == "test":
283
+ if len(args) < 3:
284
+ raise NotImplementedError(f"test {args[1:]} not implemented")
285
+ if args[1] == "-f" or args[1] == "-d":
286
+ exists = os.path.exists(args[2])
287
+ if (
288
+ not exists
289
+ or (args[1] == "-f" and not os.path.isfile(args[2]))
290
+ or (args[1] == "-d" and not os.path.isdir(args[2]))
291
+ ):
292
+ raise SystemExit(1)
293
+ return 0, True
294
+ elif args[1] == "-z":
295
+ empty = len(args[2]) == 0
296
+ if empty:
297
+ return 0, True
298
+ return 0, False
299
+ elif args[1] == "-n":
300
+ not_empty = len(args[2]) > 0
301
+ if not_empty:
302
+ return 0, True
303
+ return 0, False
304
+ else:
305
+ raise NotImplementedError(f"test {args[1:]} not implemented")
306
+ return 0, True
307
+ elif args[0] == "lus":
308
+ old_cwd = os.getcwd()
309
+ # print_command(args)
310
+ try:
311
+ self.check_args(self.main_lus_kdl, args[1:], True)
312
+ except SystemExit as e:
313
+ if e.code != 0:
314
+ raise SystemExit(e.code)
315
+ finally:
316
+ os.chdir(old_cwd)
317
+ return 0, True
318
+ elif args[0] == "export":
319
+ self.print_command(args + [f"{k}={v}" for k, v in properties.items()])
320
+ os.environ.update(properties)
321
+ return 0, True
322
+ elif args[0] == "set":
323
+ if args[1] == "-x":
324
+ self.print_commands = True
325
+ elif args[1] == "+x":
326
+ self.print_commands = False
327
+ else:
328
+ raise NotImplementedError(f"set {args[1]} not implemented")
329
+ return 0, True
330
+ elif "/" in args[0] and not os.path.isabs(args[0]):
331
+ self.print_command(args)
332
+ subprocess.check_call([os.path.join(os.getcwd(), args[0])] + args[1:])
333
+ return 0, True
334
+ else:
335
+ if not shutil.which(args[0]): # check if args[0] is in PATH
336
+ if sys.platform == "darwin": # only macOS
337
+ brew_path = shutil.which("brew")
338
+ if brew_path:
339
+ result = subprocess.check_output(
340
+ [brew_path, "which-formula", args[0]],
341
+ text=True
342
+ )
343
+ formula = result.strip()
344
+ if formula:
345
+ # ask [Y/n] if to install it now:
346
+ response = input(
347
+ f"\x1b[1;33mwarning:\x1b[0m Command '{args[0]}' not found. "
348
+ f"It is provided by the Homebrew package '\x1b[1;34m{formula}\x1b[0m'. "
349
+ "Do you want to install it now? [Y/n] "
350
+ )
351
+ if response.lower() in ["", "y", "yes"]:
352
+ self.print_command([brew_path, "install", formula])
353
+ subprocess.check_call([brew_path, "install", formula])
354
+ self.print_command(args)
355
+ subprocess.check_call(args,
356
+ shell=os.name == 'nt' # required to run .bat, .cmd, etc. on Windows
357
+ )
358
+ return 0, True
359
+
360
+ def check_args(self, nodes, args: List[str], check_if_args_handled: bool):
361
+ # Flags for this subcommand, i.e. ["--release"]
362
+ flags = []
363
+
364
+ # Everything after the last flag. For example, if the command is `lus build --release foo bar
365
+ # -v`, then this will contain `["foo", "bar", "-v"]`.
366
+ remaining_args_without_flags = []
367
+
368
+ for arg in args:
369
+ if len(remaining_args_without_flags) == 0 and arg.startswith("-"):
370
+ flags.append(arg)
371
+ else:
372
+ remaining_args_without_flags.append(arg)
373
+ remaining_args = [str(x) for x in args]
374
+
375
+ subcommand = (
376
+ remaining_args_without_flags[0] if remaining_args_without_flags else ""
377
+ )
378
+ environment = Environment(
379
+ {
380
+ "args": " ".join(remaining_args),
381
+ "subcommand": subcommand,
382
+ "invocation_directory": self._invocation_directory,
383
+ "flags": " ".join(flags),
384
+ }
385
+ )
386
+ subcommand_executed = False
387
+
388
+ subcommand_exists = any(
389
+ child.name == subcommand
390
+ for child in nodes
391
+ if len(child.name) > 0 and child.name not in ("$", "-")
392
+ )
393
+
394
+ available_subcommands = [
395
+ child.name
396
+ for child in nodes
397
+ if len(child.name) > 0
398
+ and child.name not in ("$", "-")
399
+ and child.name[0] != "-"
400
+ and child.name != ""
401
+ ]
402
+
403
+ comments = self._subcommand_comments
404
+ aliases = self._aliases
405
+
406
+ # Build a mapping of subcommand names to their flag children
407
+ subcommand_flags: Dict[str, List[str]] = {}
408
+ for child in nodes:
409
+ if (
410
+ child.name
411
+ and child.name not in ("$", "-")
412
+ and not child.name.startswith("-")
413
+ ):
414
+ child_flags = [
415
+ c.name for c in child.children if c.name.startswith("--")
416
+ ]
417
+ subcommand_flags[child.name] = child_flags
418
+
419
+ if "-l" in flags:
420
+ print("Available subcommands:")
421
+ # Compute display length including flags for proper alignment
422
+ display_parts: List[Tuple[str, str]] = [] # (name, flags_str)
423
+ for name in available_subcommands:
424
+ flags_list = subcommand_flags.get(name, [])
425
+ if flags_list:
426
+ flags_str = " ".join(f"\x1b[34m[{f}]\x1b[0m" for f in flags_list)
427
+ else:
428
+ flags_str = ""
429
+ display_parts.append((name, flags_str))
430
+
431
+ def visible_len(name, flags_str):
432
+ # Calculate visible length (without ANSI codes)
433
+ name_flags = name + (" " + flags_str if flags_str else "")
434
+ return len(self._strip_ansi(name_flags))
435
+
436
+ max_len = max(
437
+ (visible_len(name, flags_str) for name, flags_str in display_parts),
438
+ default=0,
439
+ )
440
+
441
+ for name, flags_str in display_parts:
442
+ suffix_text = ""
443
+ alias_target = aliases.get(name)
444
+ comment = comments.get(name)
445
+ if alias_target:
446
+ suffix_text = f"# alias for `{alias_target}`"
447
+ elif comment:
448
+ suffix_text = f"# {comment}"
449
+
450
+ name_with_flags = name + (" " + flags_str if flags_str else "")
451
+ if suffix_text:
452
+ visible = self._strip_ansi(name_with_flags)
453
+ padding = " " * (max_len - len(visible) + 1)
454
+ suffix = f"{padding}\x1b[32m{suffix_text}\x1b[0m"
455
+ else:
456
+ suffix = ""
457
+
458
+ flags_part = " " + flags_str if flags_str else ""
459
+ print(f" {name}{flags_part}{suffix}")
460
+ return
461
+
462
+ child_names = set()
463
+ for i, child in enumerate(nodes):
464
+ if child.name == "$" or child.name == "-" or (len(child.children) == 0 and len(child.args) > 0):
465
+ if len(child.args) > 0:
466
+ cmd = [] if child.name == "$" or child.name == "-" else [child.name]
467
+ for arg in child.args:
468
+ if arg == "$args":
469
+ # special case because it won't be passed as one argument with spaces
470
+ environment.args_used = True
471
+ if len(remaining_args) == 0:
472
+ # Only keep a placeholder when the target command needs an argument (e.g., test -n $args)
473
+ if len(cmd) > 0 and cmd[0] == "test":
474
+ cmd.append("")
475
+ else:
476
+ cmd.extend(remaining_args)
477
+ continue
478
+ cmd.append(expandvars.expand(str(arg), environ=environment, nounset=True))
479
+ if subcommand_executed and len(cmd) > 1 and cmd[0] == "lus" and cmd[1] == subcommand:
480
+ continue
481
+ self.run(cmd, child.properties)
482
+ else:
483
+ self.local_variables.update(child.properties)
484
+ continue
485
+ if child.name in child_names:
486
+ print(f"\x1b[1;31merror:\x1b[0m Duplicate node name '{child.name}'", file=sys.stderr)
487
+ raise SystemExit(1)
488
+ child_names.add(child.name)
489
+ if child.name == subcommand:
490
+ try:
491
+ remaining_args.remove(subcommand)
492
+ except ValueError:
493
+ pass # if there was a script line before that used $args, it may already be removed
494
+ try:
495
+ # Once we've matched the subcommand, enforce leftover-argument checks inside it
496
+ self.check_args(child.children, remaining_args, True)
497
+ subcommand_executed = True
498
+ except SystemExit as e:
499
+ if e.code != 0:
500
+ raise
501
+ subcommand_executed = True
502
+ remaining_args = []
503
+ elif child.name in flags:
504
+ remaining_args.remove(child.name)
505
+ self.check_args(child.children, remaining_args_without_flags, False)
506
+ # If $args was used in this block, treat the arguments as consumed even if they remain
507
+ # in the local list so subsequent commands can reuse them.
508
+ if check_if_args_handled and len(remaining_args) > 0 and not environment.args_used:
509
+ if len(available_subcommands) == 0:
510
+ print(
511
+ f"\x1b[1;31merror:\x1b[0m Unexpected argument: {shlex.join(remaining_args)}"
512
+ )
513
+ else:
514
+ print(
515
+ f"\x1b[1;31merror:\x1b[0m Unknown subcommand {shlex.quote(subcommand)} not one of:"
516
+ )
517
+ for available_subcommand in available_subcommands:
518
+ print(f" \x1b[1;34m{available_subcommand}\x1b[0m")
519
+ raise SystemExit(1)
lus/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ import subprocess
2
+ import sys
3
+ import os
4
+
5
+ from kdl.errors import ParseError
6
+
7
+ from .LusFile import LusFile
8
+ from .completions import get_completion_script
9
+
10
+
11
+ def main():
12
+ # Handle --completions flag before looking for lus.kdl
13
+ if len(sys.argv) >= 2 and sys.argv[1] == "--completions":
14
+ if len(sys.argv) < 3:
15
+ print("Usage: lus --completions <shell>", file=sys.stderr)
16
+ print("Supported shells: bash, zsh, fish, powershell", file=sys.stderr)
17
+ sys.exit(1)
18
+ shell = sys.argv[2]
19
+ try:
20
+ print(get_completion_script(shell))
21
+ except ValueError as e:
22
+ print(f"error: {e}", file=sys.stderr)
23
+ sys.exit(1)
24
+ return
25
+
26
+ try:
27
+ invocation_directory = os.getcwd()
28
+ MAX_DEPTH = 50
29
+ current_filesystem = os.stat(".").st_dev
30
+ for i in range(MAX_DEPTH):
31
+ try:
32
+ with open("lus.kdl", "r") as f:
33
+ content = f.read()
34
+ except FileNotFoundError as e:
35
+ if current_filesystem != os.stat("..").st_dev:
36
+ raise e
37
+ cwd = os.getcwd()
38
+ os.chdir("..")
39
+ if cwd == os.getcwd():
40
+ raise e
41
+ else:
42
+ break
43
+
44
+ file = LusFile(content, invocation_directory)
45
+ except subprocess.CalledProcessError as e:
46
+ sys.exit(e.returncode)
47
+ except FileNotFoundError as e:
48
+ print(f"\x1b[1;31merror:\x1b[0m {e.strerror}: {e.filename}", file=sys.stderr)
49
+ sys.exit(1)
50
+ except KeyboardInterrupt:
51
+ sys.exit(130)
52
+ except ParseError as e:
53
+ print(f"\x1b[1;31merror:\x1b[0m lus.kdl:{e}", file=sys.stderr)
54
+ sys.exit(1)
lus/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from . import main
2
+ main()
lus/completions.py ADDED
@@ -0,0 +1,142 @@
1
+ """Shell completion scripts for lus."""
2
+
3
+ BASH_COMPLETION = """
4
+ _lus_completions() {
5
+ local cur prev
6
+ cur="${COMP_WORDS[COMP_CWORD]}"
7
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
8
+
9
+ # lus options
10
+ if [[ "$cur" == -* ]]; then
11
+ COMPREPLY=($(compgen -W "-l --completions" -- "$cur"))
12
+ return
13
+ fi
14
+
15
+ # Complete shell names after --completions
16
+ if [[ "$prev" == "--completions" ]]; then
17
+ COMPREPLY=($(compgen -W "bash zsh fish powershell" -- "$cur"))
18
+ return
19
+ fi
20
+
21
+ # Get subcommands from lus -l
22
+ local subcommands
23
+ subcommands=$(lus -l 2>/dev/null | tail -n +2 | awk '{print $1}' | sed 's/\\x1b\\[[0-9;]*m//g')
24
+
25
+ if [[ -n "$subcommands" ]]; then
26
+ COMPREPLY=($(compgen -W "$subcommands" -- "$cur"))
27
+ fi
28
+ }
29
+
30
+ complete -F _lus_completions lus
31
+ """
32
+
33
+ ZSH_COMPLETION = """
34
+ #compdef lus
35
+
36
+ _lus() {
37
+ local -a subcommands
38
+ local -a options
39
+
40
+ options=(
41
+ '-l[List available subcommands]'
42
+ '--completions[Generate shell completion script]:shell:(bash zsh fish powershell)'
43
+ )
44
+
45
+ # Get subcommands from lus -l
46
+ if [[ -f lus.kdl ]] || _lus_find_kdl; then
47
+ subcommands=(${(f)"$(lus -l 2>/dev/null | tail -n +2 | awk '{print $1}' | sed 's/\\x1b\\[[0-9;]*m//g')"})
48
+ fi
49
+
50
+ _arguments -s \\
51
+ $options \\
52
+ '*:subcommand:($subcommands)'
53
+ }
54
+
55
+ _lus_find_kdl() {
56
+ local dir="$PWD"
57
+ while [[ "$dir" != "/" ]]; do
58
+ [[ -f "$dir/lus.kdl" ]] && return 0
59
+ dir="${dir:h}"
60
+ done
61
+ return 1
62
+ }
63
+
64
+ compdef _lus lus
65
+ """
66
+
67
+ FISH_COMPLETION = """
68
+ # Fish completion for lus
69
+
70
+ function __lus_subcommands
71
+ lus -l 2>/dev/null | tail -n +2 | awk '{print $1}' | sed 's/\\x1b\\[[0-9;]*m//g'
72
+ end
73
+
74
+ # Disable file completions
75
+ complete -c lus -f
76
+
77
+ # Options
78
+ complete -c lus -s l -d "List available subcommands"
79
+ complete -c lus -l completions -xa "bash zsh fish powershell" -d "Generate shell completion script"
80
+
81
+ # Subcommands
82
+ complete -c lus -a "(__lus_subcommands)" -d "Subcommand"
83
+ """
84
+
85
+ POWERSHELL_COMPLETION = """
86
+ # PowerShell completion for lus
87
+
88
+ Register-ArgumentCompleter -Native -CommandName lus -ScriptBlock {
89
+ param($wordToComplete, $commandAst, $cursorPosition)
90
+
91
+ $options = @('-l', '--completions')
92
+
93
+ # If completing an option
94
+ if ($wordToComplete -like '-*') {
95
+ $options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
96
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
97
+ }
98
+ return
99
+ }
100
+
101
+ # If previous word was --completions, complete shell names
102
+ $words = $commandAst.CommandElements
103
+ if ($words.Count -ge 2 -and $words[-2].Extent.Text -eq '--completions') {
104
+ @('bash', 'zsh', 'fish', 'powershell') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
105
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
106
+ }
107
+ return
108
+ }
109
+
110
+ # Get subcommands from lus -l
111
+ try {
112
+ $output = lus -l 2>$null
113
+ if ($output) {
114
+ $output | Select-Object -Skip 1 | ForEach-Object {
115
+ # Extract first word and strip ANSI codes
116
+ $line = $_ -replace '\\x1b\\[[0-9;]*m', ''
117
+ $subcommand = ($line -split '\\s+')[0]
118
+ if ($subcommand -and $subcommand -like "$wordToComplete*") {
119
+ [System.Management.Automation.CompletionResult]::new($subcommand, $subcommand, 'Command', $subcommand)
120
+ }
121
+ }
122
+ }
123
+ } catch {
124
+ # Silently ignore errors
125
+ }
126
+ }
127
+ """
128
+
129
+
130
+ def get_completion_script(shell: str) -> str:
131
+ """Return the completion script for the given shell."""
132
+ scripts = {
133
+ "bash": BASH_COMPLETION.strip(),
134
+ "zsh": ZSH_COMPLETION.strip(),
135
+ "fish": FISH_COMPLETION.strip(),
136
+ "powershell": POWERSHELL_COMPLETION.strip(),
137
+ }
138
+ if shell not in scripts:
139
+ raise ValueError(
140
+ f"Unknown shell: {shell}. Supported shells: bash, zsh, fish, powershell"
141
+ )
142
+ return scripts[shell]
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: lus
3
+ Version: 0.4.0
4
+ Summary: A simple task-runner using KDL for configuration
5
+ Home-page: https://github.com/jhasse/lus
6
+ Download-URL: https://github.com/jhasse/lus/archive/v0.4.0.tar.gz
7
+ Author-email: Jan Niklas Hasse <jhasse@bixense.com>
8
+ Requires-Python: >=3.7
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE.txt
11
+ Requires-Dist: kdl-py
12
+ Requires-Dist: expandvars
13
+ Dynamic: download-url
14
+ Dynamic: home-page
15
+ Dynamic: license-file
16
+
17
+ # lus
18
+
19
+ `lus` is a task runner similar to [just](https://just.systems). It's key differentiators are:
20
+
21
+ * No DSL, `lus` uses the existing [KDL](https://kdl.dev)
22
+ * Runs tasks directly without a shell
23
+ * Comes with a simple built-in shell, so it works out-of-the-box on Windows
24
+ * Less features
25
+
26
+ ```kdl
27
+ b {
28
+ - lus build
29
+ }
30
+
31
+ - host="$(uname -a)"
32
+
33
+ // build main
34
+ build {
35
+ - cc *.a -o main
36
+ }
37
+
38
+ // test everything
39
+ test-all {
40
+ - lus build
41
+ - "./test" --all
42
+ }
43
+
44
+ // run a specific test
45
+ test {
46
+ - lus build
47
+ - "./test" --test $args
48
+ }
49
+ ```
50
+
51
+ ## Special environment variables
52
+
53
+ | Variable | Description |
54
+ |----------------------------|------------------------------------|
55
+ | `$args` | Additional arguments passed to lus |
56
+ | `$subcommand` | Current subcommand being executed |
57
+ | `$flags` | Arguments starting with `--` |
58
+ | `$invocation_directory` | Directory where `lus` was invoked |
59
+
60
+ ## Shell Completions
61
+
62
+ `lus` supports tab completion for bash, zsh, fish, and PowerShell. Add one of the following to your shell configuration:
63
+
64
+ **Bash** (`~/.bashrc`):
65
+ ```bash
66
+ eval "$(lus --completions bash)"
67
+ ```
68
+
69
+ **Zsh** (`~/.zshrc`):
70
+ ```zsh
71
+ autoload -Uz compinit && compinit; source <(lus --completions zsh)
72
+ ```
73
+
74
+ **Fish** (`~/.config/fish/config.fish`):
75
+ ```fish
76
+ lus --completions fish | source
77
+ ```
78
+
79
+ **PowerShell** (`$PROFILE`):
80
+ ```powershell
81
+ Invoke-Expression (& lus --completions powershell)
82
+ ```
83
+
84
+ # Development
85
+
86
+ Run unit and integration tests:
87
+
88
+ ```
89
+ python -m venv .venv
90
+ . .venv/bin/activate.fish
91
+ pip install kdl-py expandvars pytest
92
+ pytest
93
+ ```
@@ -0,0 +1,10 @@
1
+ lus/LusFile.py,sha256=1FpFHmLEHjwSeMP6s2cH2x2MahQve4fp3VrAREFMFQg,19958
2
+ lus/__init__.py,sha256=ycZjiQ05d-FP2VaR-c-iaBe1ILuLVGsrp3n-ThcvC3M,1720
3
+ lus/__main__.py,sha256=IvTjTYhvVO6h9Jpa45i-FfzjcKcJy1jwkAZY_CkgibQ,26
4
+ lus/completions.py,sha256=zGx0i1KMI_MmwPnbhcN0mZIhijltTXaPz-nRQvnDybU,4015
5
+ lus-0.4.0.dist-info/licenses/LICENSE.txt,sha256=Dlf7J847Sv7Sp0DylzsF0MOjOT91KRf5C6QB04u-2oE,855
6
+ lus-0.4.0.dist-info/METADATA,sha256=_Xb2ZDAVL2OpJ2FfFDdu5O0d65n3EyB5AJIPzvExgmk,2094
7
+ lus-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ lus-0.4.0.dist-info/entry_points.txt,sha256=LPfiwox0QhgxGJHyk0b69jX1wo6PP3gFrwWLFIbACVw,33
9
+ lus-0.4.0.dist-info/top_level.txt,sha256=o6MNyIt59spkp4tkVEH814sF9OzCIBASldrtkpICn10,4
10
+ lus-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lus = lus:main
@@ -0,0 +1,17 @@
1
+ Copyright (c) 2025 Jan Niklas Hasse
2
+
3
+ This software is provided 'as-is', without any express or implied
4
+ warranty. In no event will the authors be held liable for any damages
5
+ arising from the use of this software.
6
+
7
+ Permission is granted to anyone to use this software for any purpose,
8
+ including commercial applications, and to alter it and redistribute it
9
+ freely, subject to the following restrictions:
10
+
11
+ 1. The origin of this software must not be misrepresented; you must not
12
+ claim that you wrote the original software. If you use this software
13
+ in a product, an acknowledgment in the product documentation would be
14
+ appreciated but is not required.
15
+ 2. Altered source versions must be plainly marked as such, and must not be
16
+ misrepresented as being the original software.
17
+ 3. This notice may not be removed or altered from any source distribution.
@@ -0,0 +1 @@
1
+ lus