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 +519 -0
- lus/__init__.py +54 -0
- lus/__main__.py +2 -0
- lus/completions.py +142 -0
- lus-0.4.0.dist-info/METADATA +93 -0
- lus-0.4.0.dist-info/RECORD +10 -0
- lus-0.4.0.dist-info/WHEEL +5 -0
- lus-0.4.0.dist-info/entry_points.txt +2 -0
- lus-0.4.0.dist-info/licenses/LICENSE.txt +17 -0
- lus-0.4.0.dist-info/top_level.txt +1 -0
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
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,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
|