userun 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
userun-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.3
2
+ Name: userun
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Edward Boswell
6
+ Author-email: Edward Boswell <thememium@gmail.com>
7
+ Requires-Dist: asyncio>=4.0.0
8
+ Requires-Dist: usecli>=0.1.48
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
userun-0.1.0/README.md ADDED
File without changes
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "userun"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [{ name = "Edward Boswell", email = "thememium@gmail.com" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "asyncio>=4.0.0",
10
+ "usecli>=0.1.48",
11
+ ]
12
+
13
+ [project.scripts]
14
+ userun = "usecli:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.10.2,<0.11.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "deptry>=0.24.0",
23
+ "isort>=8.0.1",
24
+ "poethepoet>=0.42.1",
25
+ "pytest>=9.0.2",
26
+ "ruff>=0.15.5",
27
+ "ty>=0.0.21",
28
+ "usechange>=0.1.28",
29
+ ]
30
+
31
+ [tool.deptry]
32
+ known_first_party = ["userun"]
33
+
34
+ [tool.deptry.per_rule_ignores]
35
+ DEP002 = ["asyncio"]
36
+ DEP005 = ["asyncio"]
37
+
38
+ [tool.poe.tasks]
39
+ dev = "uv run main.py"
40
+ clean-full = "sh -c 'uv run isort . && uv run ruff check . --fix && uv run ruff format . && uv run deptry . && uv run ty check'"
41
+ clean = "sh -c 'uv run isort . && uv run ruff format .'"
42
+ test = "uv run pytest tests/ -v"
43
+ sort = "uv run isort ."
44
+ lint = "uv run ruff check ."
45
+ format = "uv run ruff format ."
46
+ deptry = "uv deptry ."
47
+ typecheck = "uv run ty check"
48
+ release = "uv run usechange release"
49
+
50
+ concurrent-demo = "uv run userun concurrent -n server,lint,test \"python3 -m http.server 8000\" \"uv run poe lint\" \"uv run poe test\""
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["."]
54
+ include = ["src*"]
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from userun!")
File without changes
File without changes
@@ -0,0 +1,323 @@
1
+ """ConcurrentCommand - CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import TextIO
10
+
11
+ from usecli import Argument, BaseCommand, Option, console, theme
12
+
13
+
14
+ class ConcurrentCommand(BaseCommand):
15
+ def signature(self) -> str:
16
+ return "concurrent"
17
+
18
+ def description(self) -> str:
19
+ return "Run multiple commands concurrently"
20
+
21
+ def aliases(self) -> list[str]:
22
+ return ["conc"]
23
+
24
+ @dataclass(frozen=True)
25
+ class CommandSpec:
26
+ index: int
27
+ command: str
28
+ prefix: str
29
+
30
+ @staticmethod
31
+ def parse_csv(value: str | None) -> list[str]:
32
+ if value is None or not isinstance(value, str):
33
+ return []
34
+ items = [item.strip() for item in value.split(",")]
35
+ return [item for item in items if item]
36
+
37
+ @staticmethod
38
+ def resolve_color(name: str) -> str | None:
39
+ normalized = name.strip().lower()
40
+ if not normalized:
41
+ return None
42
+ palette = {
43
+ "primary": theme.ANSI.PRIMARY,
44
+ "secondary": theme.ANSI.SECONDARY,
45
+ "accent": theme.ANSI.ACCENT,
46
+ "blue": theme.ANSI.BLUE,
47
+ "green": theme.ANSI.GREEN,
48
+ "yellow": theme.ANSI.YELLOW,
49
+ "red": theme.ANSI.RED,
50
+ "foreground": theme.ANSI.FOREGROUND,
51
+ "foreground-muted": theme.ANSI.FOREGROUND_MUTED,
52
+ "foreground_muted": theme.ANSI.FOREGROUND_MUTED,
53
+ "gray": theme.ANSI.FOREGROUND_MUTED,
54
+ "grey": theme.ANSI.FOREGROUND_MUTED,
55
+ "white": theme.ANSI.FOREGROUND,
56
+ "cyan": theme.ANSI.PRIMARY,
57
+ "magenta": theme.ANSI.ACCENT,
58
+ "purple": theme.ANSI.ACCENT,
59
+ }
60
+ return palette.get(normalized)
61
+
62
+ async def stream_output(
63
+ self, stream: asyncio.StreamReader, prefix: str, output: TextIO
64
+ ) -> None:
65
+ while True:
66
+ line = await stream.readline()
67
+ if not line:
68
+ break
69
+ text = line.decode(errors="replace")
70
+ if not text.endswith("\n"):
71
+ text = f"{text}\n"
72
+ output.write(f"{prefix}{text}")
73
+ output.flush()
74
+
75
+ @staticmethod
76
+ def strip_ansi(text: str) -> str:
77
+ return re.sub(r"\x1b\[[0-9;]*m", "", text)
78
+
79
+ def build_prefixes(
80
+ self,
81
+ commands: list[str],
82
+ *,
83
+ names: list[str] | None = None,
84
+ colors: list[str] | None = None,
85
+ prefix_format: str | None = None,
86
+ no_prefix: bool = False,
87
+ no_color: bool = False,
88
+ ) -> list[str]:
89
+ if no_prefix:
90
+ return [""] * len(commands)
91
+
92
+ default_colors = [
93
+ theme.ANSI.PRIMARY,
94
+ theme.ANSI.SECONDARY,
95
+ theme.ANSI.ACCENT,
96
+ theme.ANSI.BLUE,
97
+ theme.ANSI.GREEN,
98
+ theme.ANSI.YELLOW,
99
+ ]
100
+ resolved_colors = colors or []
101
+ palette = resolved_colors or default_colors
102
+ reset = "" if no_color else theme.ANSI.RESET
103
+
104
+ index_width = len(str(max(len(commands) - 1, 0)))
105
+ prefixes: list[str] = []
106
+ for index in range(len(commands)):
107
+ name = ""
108
+ if names and index < len(names):
109
+ name = names[index]
110
+ label = name if name else f"{index:{index_width}d}"
111
+ format_value = {
112
+ "index": index,
113
+ "name": name or str(index),
114
+ "label": label,
115
+ }
116
+ raw_prefix = None
117
+ if prefix_format:
118
+ try:
119
+ raw_prefix = prefix_format.format_map(format_value)
120
+ except (KeyError, ValueError):
121
+ raw_prefix = None
122
+ if raw_prefix is None:
123
+ raw_prefix = f"[{label}]"
124
+
125
+ if no_color or not palette:
126
+ prefixes.append(f"{raw_prefix} ")
127
+ continue
128
+ color = palette[index % len(palette)]
129
+ prefixes.append(f"{color}{raw_prefix}{reset} ")
130
+ return prefixes
131
+
132
+ async def run_command(
133
+ self,
134
+ spec: CommandSpec,
135
+ queue: asyncio.Queue[str | None],
136
+ *,
137
+ failure_event: asyncio.Event | None = None,
138
+ process_registry: dict[int, asyncio.subprocess.Process] | None = None,
139
+ registry_lock: asyncio.Lock | None = None,
140
+ subprocess_color: bool = True,
141
+ ) -> int:
142
+ env = None
143
+ if subprocess_color:
144
+ env = os.environ.copy()
145
+ env.update(
146
+ {
147
+ "FORCE_COLOR": "1",
148
+ "CLICOLOR_FORCE": "1",
149
+ "RICH_FORCE_TERMINAL": "1",
150
+ "TERM": env.get("TERM", "xterm-256color"),
151
+ }
152
+ )
153
+ process = await asyncio.create_subprocess_shell(
154
+ spec.command,
155
+ stdout=asyncio.subprocess.PIPE,
156
+ stderr=asyncio.subprocess.PIPE,
157
+ env=env,
158
+ )
159
+ if process_registry is not None and registry_lock is not None:
160
+ async with registry_lock:
161
+ process_registry[spec.index] = process
162
+ await queue.put(f"{spec.prefix}started: {spec.command}\n")
163
+
164
+ async def read_and_queue(stream: asyncio.StreamReader) -> None:
165
+ while True:
166
+ line = await stream.readline()
167
+ if not line:
168
+ break
169
+ text = line.decode(errors="replace")
170
+ if not text.endswith("\n"):
171
+ text = f"{text}\n"
172
+ if not subprocess_color:
173
+ text = self.strip_ansi(text)
174
+ await queue.put(f"{spec.prefix}{text}")
175
+
176
+ tasks: list[asyncio.Task[None]] = []
177
+ if process.stdout is not None:
178
+ tasks.append(asyncio.create_task(read_and_queue(process.stdout)))
179
+ if process.stderr is not None:
180
+ tasks.append(asyncio.create_task(read_and_queue(process.stderr)))
181
+
182
+ return_code = await process.wait()
183
+ if tasks:
184
+ await asyncio.gather(*tasks)
185
+ await queue.put(
186
+ f"{spec.prefix}exited with code {return_code}: {spec.command}\n"
187
+ )
188
+ if process_registry is not None and registry_lock is not None:
189
+ async with registry_lock:
190
+ process_registry.pop(spec.index, None)
191
+ if failure_event is not None and return_code != 0:
192
+ failure_event.set()
193
+ return return_code
194
+
195
+ async def run_all(
196
+ self,
197
+ commands: list[str],
198
+ *,
199
+ names: list[str] | None = None,
200
+ colors: list[str] | None = None,
201
+ prefix_format: str | None = None,
202
+ no_prefix: bool = False,
203
+ no_color: bool = False,
204
+ kill_others: bool = False,
205
+ subprocess_color: bool = True,
206
+ ) -> list[int]:
207
+ prefixes = self.build_prefixes(
208
+ commands,
209
+ names=names,
210
+ colors=colors,
211
+ prefix_format=prefix_format,
212
+ no_prefix=no_prefix,
213
+ no_color=no_color,
214
+ )
215
+ specs = [
216
+ self.CommandSpec(index=index, command=command, prefix=prefixes[index])
217
+ for index, command in enumerate(commands)
218
+ ]
219
+ queue: asyncio.Queue[str | None] = asyncio.Queue()
220
+ failure_event = asyncio.Event() if kill_others else None
221
+ process_registry: dict[int, asyncio.subprocess.Process] = {}
222
+ registry_lock = asyncio.Lock()
223
+
224
+ async def writer() -> None:
225
+ while True:
226
+ item = await queue.get()
227
+ if item is None:
228
+ break
229
+ console.print(item, end="", markup=False, highlight=False)
230
+
231
+ writer_task = asyncio.create_task(writer())
232
+ results_task = asyncio.gather(
233
+ *(
234
+ self.run_command(
235
+ spec,
236
+ queue,
237
+ failure_event=failure_event,
238
+ process_registry=process_registry,
239
+ registry_lock=registry_lock,
240
+ subprocess_color=subprocess_color,
241
+ )
242
+ for spec in specs
243
+ )
244
+ )
245
+
246
+ if kill_others and failure_event is not None:
247
+ await failure_event.wait()
248
+ async with registry_lock:
249
+ for process in process_registry.values():
250
+ if process.returncode is None:
251
+ process.terminate()
252
+
253
+ results = await results_task
254
+ await queue.put(None)
255
+ await writer_task
256
+ return list(results)
257
+
258
+ def handle(
259
+ self,
260
+ commands: list[str] = Argument(
261
+ ..., help="Commands to run concurrently. Quote each command."
262
+ ),
263
+ names: str | None = Option(
264
+ None,
265
+ "--names",
266
+ "-n",
267
+ help="Comma-separated names to label each command.",
268
+ ),
269
+ colors: str | None = Option(
270
+ None,
271
+ "--colors",
272
+ "-c",
273
+ help="Comma-separated color names for prefixes.",
274
+ ),
275
+ kill_others: bool = Option(
276
+ False,
277
+ "--kill-others",
278
+ "-k",
279
+ help="Stop remaining commands when a command exits non-zero.",
280
+ ),
281
+ prefix_format: str | None = Option(
282
+ None,
283
+ "--prefix-format",
284
+ "-p",
285
+ help="Custom prefix format using {index}, {name}, {label}.",
286
+ ),
287
+ no_prefix: bool = Option(
288
+ False,
289
+ "--no-prefix",
290
+ help="Disable output prefixes.",
291
+ ),
292
+ no_color: bool = Option(
293
+ False,
294
+ "--no-color",
295
+ help="Disable ANSI colors in prefixes.",
296
+ ),
297
+ subprocess_color: bool = Option(
298
+ True,
299
+ "--subprocess-color/--no-subprocess-color",
300
+ help="Enable or disable ANSI colors from subprocess output.",
301
+ ),
302
+ ) -> None:
303
+ name_list = self.parse_csv(names)
304
+ color_names = self.parse_csv(colors)
305
+ resolved_colors: list[str] = []
306
+ for color_name in color_names:
307
+ resolved = self.resolve_color(color_name)
308
+ if resolved:
309
+ resolved_colors.append(resolved)
310
+ exit_codes = asyncio.run(
311
+ self.run_all(
312
+ commands,
313
+ names=name_list,
314
+ colors=resolved_colors,
315
+ prefix_format=prefix_format,
316
+ no_prefix=no_prefix,
317
+ no_color=no_color,
318
+ kill_others=kill_others,
319
+ subprocess_color=subprocess_color,
320
+ )
321
+ )
322
+ if any(code != 0 for code in exit_codes):
323
+ raise SystemExit(1)
@@ -0,0 +1,67 @@
1
+ """{{ class_name }} - CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from usecli import Argument, BaseCommand, Confirm, Menu, Option, Prompt, console, theme
6
+
7
+
8
+ class {{ class_name }}(BaseCommand):
9
+ def signature(self) -> str:
10
+ return "{{ command_name }}"
11
+
12
+ def description(self) -> str:
13
+ return "Description for {{ command_name }} command"
14
+
15
+ # def aliases(self) -> list[str]:
16
+ # return [{{ command_name[:3] }}]
17
+
18
+ def handle(
19
+ self,
20
+ name: str = Argument(..., help="An example argument"),
21
+ verbose: bool = Option(False, "--verbose", "-v", help="Enable verbose output"),
22
+ ) -> None:
23
+ console.print(
24
+ f"[bold {theme.SUCCESS}]Executing {{ command_name }}[/bold {theme.SUCCESS}]"
25
+ )
26
+ console.print(f"Hello, {name}!")
27
+
28
+ if verbose:
29
+ console.print("[dim]Verbose mode enabled[/dim]")
30
+
31
+ # Example: Text input prompt
32
+ favorite_color = Prompt.ask(
33
+ "What's your favorite color?",
34
+ choices=["red", "green", "blue", "yellow"],
35
+ )
36
+ console.print(
37
+ f"You chose: [bold {theme.ACCENT}]{favorite_color}[/bold {theme.ACCENT}]"
38
+ )
39
+
40
+ # Example: Confirmation prompt (only when verbose)
41
+ if verbose:
42
+ if not Confirm.ask("Do you want to continue?"):
43
+ console.print(f"[{theme.WARNING}]Cancelled by user[/{theme.WARNING}]")
44
+ return
45
+ console.print(f"[{theme.SUCCESS}]Proceeding...[/{theme.SUCCESS}]")
46
+
47
+ # Example: Single-select menu
48
+ single_choice = Menu.select(
49
+ ["Option A", "Option B", "Option C"],
50
+ title="Pick one option:",
51
+ )
52
+ if single_choice:
53
+ console.print(f"You selected: {single_choice}")
54
+
55
+ # Example: Multi-select menu
56
+ multi_choices = Menu.multi_select(
57
+ ["Feature 1", "Feature 2", "Feature 3", "Feature 4"],
58
+ title="Select multiple features (space to select, enter to confirm):",
59
+ )
60
+ if multi_choices:
61
+ console.print(f"You selected {len(multi_choices)} features:")
62
+ for choice in multi_choices:
63
+ console.print(f" - {choice}")
64
+
65
+ console.print(
66
+ f"[bold {theme.PRIMARY}]Command completed![/bold {theme.PRIMARY}]"
67
+ )
@@ -0,0 +1,29 @@
1
+ [colors]
2
+ # Core semantic colors
3
+ primary = "#60D7FF"
4
+ secondary = "#5EFF87"
5
+ accent = "#F5FE53"
6
+
7
+ success = "#5EFF87" # Green
8
+ error = "#FE686B" # Red
9
+ warning = "#F5FE53" # Yellow
10
+ info = "#60D7FF" # Teal / Blue
11
+
12
+ # Text
13
+ foreground = "#FFFFFF" # Text
14
+ foreground_muted = "#BBBBBB" # Subtext
15
+
16
+ # Surfaces
17
+ background = "#000000" # Base
18
+ border = "#60D7FF" # Surface
19
+ border_focus = "#5EFF87" # Focus Ring
20
+
21
+ # UI semantics
22
+ command = "#60D7FF"
23
+ option = "#60D7FF"
24
+ link = "#60D7FF"
25
+ prompt = "#5EFF87"
26
+
27
+ panel_primary = "#5EFF87"
28
+ panel_secondary = "#60D7FF"
29
+ panel_accent = "#F5FE53"
@@ -0,0 +1,7 @@
1
+ ▀██▀▀█▄
2
+ ▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ██ ██ ▄▄▄ ▄▄▄ ▄▄ ▄▄▄
3
+ ██ ██ ██▄ ▀ ▄█▄▄▄██ ██▀▀█▀ ██ ██ ██ ██
4
+ ██ ██ ▄ ▀█▄▄ ██ ██ █▄ ██ ██ ██ ██
5
+ ▀█▄▄▀█▄ █▀▄▄█▀ ▀█▄▄▄▀ ▄██▄ ▀█▀ ▀█▄▄▀█▄ ▄██▄ ██▄
6
+
7
+ █████▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓█████
@@ -0,0 +1,14 @@
1
+ [usecli]
2
+ command_name = "userun"
3
+ title = "userun"
4
+ title_file = "title.txt"
5
+ title_font = "ansi_shadow"
6
+ description = "A python command runner for concurrent runs."
7
+ commands_dir = "commands"
8
+ templates_dir = "templates"
9
+ themes_dir = "themes"
10
+ theme = "default"
11
+ hide_init = true
12
+ hide_inspire = true
13
+ hide_make_command = false
14
+ hide_make_theme = true