lid-cli 0.1__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.
lid/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """lid-cli."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ import importlib.metadata
10
+
11
+ TYPE_CHECKING = False
12
+ if TYPE_CHECKING:
13
+ from types import ModuleType
14
+
15
+ from lid import gio, io, subprocess, types
16
+ else:
17
+
18
+ def __getattr__(key: str, /) -> ModuleType:
19
+ if key in __all__:
20
+ return importlib.import_module(f"{__package__}.{key}", package=__package__)
21
+ raise AttributeError(f"module `{__name__}` has not attribute `{key}`")
22
+
23
+
24
+ __version__ = importlib.metadata.version("lid-cli")
25
+ __all__ = ["__version__", "gio", "io", "subprocess", "types"]
lid/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """CLI entry point."""
5
+
6
+ from __future__ import annotations
7
+
8
+ if __name__ == "__main__":
9
+ pass
lid/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """CLI."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import itertools as it
9
+ import logging
10
+ import pathlib as pl
11
+ from typing import Annotated
12
+
13
+ import rich.console
14
+ import rich.theme
15
+ import typer
16
+
17
+ import lid
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ env: lid.types.Environment
22
+ console: rich.console.Console
23
+
24
+ _HELP = """
25
+ Resource activation and communication helper.
26
+ """
27
+
28
+ app = typer.Typer(help=_HELP, no_args_is_help=True, rich_markup_mode="markdown")
29
+
30
+
31
+ @app.callback()
32
+ def _setup_io() -> None:
33
+ global env, console # noqa: PLW0603
34
+
35
+ import logging
36
+
37
+ theme = rich.theme.Theme(
38
+ {
39
+ "info": "italic yellow",
40
+ "warning": "italic red",
41
+ "error": "bold bright_red",
42
+ }
43
+ )
44
+ console = rich.console.Console(
45
+ theme=theme,
46
+ # STDOUT is used for piping; formatted output should go to stderr.
47
+ stderr=True,
48
+ )
49
+ try:
50
+ env = lid.io.capture_environment()
51
+ except lid.types.EnvCaptureError as err:
52
+ console.print("Failed to capture environment.", style="error")
53
+ raise typer.Exit(2) from err
54
+
55
+ if env.log_level is None:
56
+ logging.basicConfig(level=logging.CRITICAL)
57
+ else:
58
+ from pathlib import Path
59
+
60
+ import msgspec
61
+
62
+ logging.basicConfig(
63
+ filename=Path.cwd() / ".lid.jsonl",
64
+ encoding="utf-8",
65
+ level=env.log_level,
66
+ force=True,
67
+ )
68
+
69
+ _encode_json = msgspec.json.Encoder().encode
70
+
71
+ class JsonLineFormatter(logging.Formatter):
72
+ def format(self, record: logging.LogRecord) -> str:
73
+ return _encode_json(
74
+ {
75
+ "time": self.formatTime(record),
76
+ "level": record.levelname,
77
+ "logger": record.name,
78
+ "message": record.getMessage(),
79
+ }
80
+ ).decode("utf-8")
81
+
82
+ root_logger = logging.getLogger()
83
+ for handler in root_logger.handlers:
84
+ handler.setFormatter(JsonLineFormatter())
85
+
86
+
87
+ # Use `list` as return value since type must be available at runtime for typer to work.
88
+ # Argument is passed as kwarg by typer.
89
+ def _complete_mount_kw(incomplete: str) -> list[str]:
90
+ import asyncio
91
+
92
+ env = lid.io.capture_environment()
93
+ return asyncio.run(
94
+ lid.gio.find_device_names(
95
+ incomplete, mounted_only=False, env=env, runner=lid.subprocess.run
96
+ )
97
+ )
98
+
99
+
100
+ def _require_nonempty(param: typer.CallbackParam, v: str | None) -> str | None:
101
+ if v is None or v:
102
+ return v
103
+ raise typer.BadParameter(f"parameter `{param.name}` must not be an empty string")
104
+
105
+
106
+ @app.command()
107
+ def mount(
108
+ kw: Annotated[
109
+ str,
110
+ typer.Argument(
111
+ help="Device keyword.",
112
+ autocompletion=_complete_mount_kw,
113
+ callback=_require_nonempty,
114
+ ),
115
+ ],
116
+ ) -> int:
117
+ import asyncio
118
+ import uuid
119
+
120
+ import msgspec
121
+
122
+ with asyncio.Runner() as runner:
123
+ try:
124
+ mt = runner.run(lid.gio.mount(kw, env=env, runner=lid.subprocess.run))
125
+ except lid.types.GioNotFoundError as err:
126
+ log.exception("Failed to mount for keyword `%s`.", kw)
127
+ raise typer.BadParameter(f"Device not determined: {err}.") from None
128
+ except lid.types.GioMountError as err:
129
+ log.exception("Failed to mount for keyword `%s`.", kw)
130
+ console.print(f"Mount failure: {err}.", style="error")
131
+ raise typer.Exit(2) from None
132
+
133
+ console.print(f"Mounted `{mt.name}`.", style="info")
134
+ if mt.triggered:
135
+ mt_info = msgspec.json.encode(mt)
136
+ try:
137
+ env.runtime_dir.mkdir(parents=True, exist_ok=True)
138
+ env.runtime_dir.joinpath(str(uuid.uuid4())).write_bytes(mt_info)
139
+ except OSError, PermissionError:
140
+ log.exception("Failed to create mount marker file.")
141
+ try:
142
+ runner.run(
143
+ lid.gio.umount(mount=mt, env=env, runner=lid.subprocess.run)
144
+ )
145
+ except lid.types.GioMountError as err:
146
+ console.print(
147
+ f"Failed to create marker file, then failed to umount: {err}.",
148
+ style="error",
149
+ )
150
+ raise typer.Exit(2) from None
151
+
152
+ # Needs to be unformatted to not confuse receiver (listening on STDOUT).
153
+ print(mt.mountpoint)
154
+ return 0
155
+
156
+
157
+ def _complete_umount_kw(incomplete: str) -> list[str]:
158
+ import asyncio
159
+
160
+ env = lid.io.capture_environment()
161
+ return asyncio.run(
162
+ lid.gio.find_device_names(
163
+ incomplete, mounted_only=True, env=env, runner=lid.subprocess.run
164
+ )
165
+ )
166
+
167
+
168
+ async def _umount(kw: str, /, env: lid.types.Environment) -> int:
169
+ import asyncio
170
+
171
+ names, markers = await asyncio.gather(
172
+ lid.gio.find_device_names(
173
+ kw, mounted_only=True, env=env, runner=lid.subprocess.run
174
+ ),
175
+ asyncio.to_thread(lid.io.read_mount_markers, env),
176
+ )
177
+ match [(p, m) for n, (p, m) in it.product(names, markers) if n == m.name]:
178
+ case []:
179
+ log.info("Found no matching marker file. Exiting.")
180
+ return 0
181
+ case [[pl.Path() as f, lid.types.Mount() as mt]]:
182
+ try:
183
+ await lid.gio.umount(mount=mt, env=env, runner=lid.subprocess.run)
184
+ except lid.types.GioNotFoundError as err:
185
+ log.exception("Failed to mount for keyword `%s` (match: `%s`.", kw, mt)
186
+ raise typer.BadParameter(f"Device not determined: {err}.") from None
187
+ except lid.types.GioMountError as err:
188
+ log.exception(
189
+ "Failed to unmount for keyword `%s` (match: `%s`).", kw, mt
190
+ )
191
+ console.print(f"Unmount failure: {err}.", style="error")
192
+ raise typer.Exit(2) from None
193
+
194
+ console.print(f"Unmounted `{mt.name}`.", style="info")
195
+
196
+ try:
197
+ f.unlink()
198
+ except OSError, PermissionError:
199
+ console.print(
200
+ f"Failed to remove marker file `{f.as_posix()}`.", style="error"
201
+ )
202
+ raise typer.Exit(2) from None
203
+ return 0
204
+ case [[pl.Path() as f, fmt], [pl.Path() as s, smt], *m]:
205
+ raise typer.BadParameter(
206
+ f"Keyword `{kw}` matches `{fmt.name}` (file:`{f.name}`), "
207
+ f"`{smt.name}` (file: `{s.name}`), and {len(m)} more."
208
+ )
209
+
210
+ console.print(
211
+ "Discovered unsupported case. No action will be taken.", style="error"
212
+ )
213
+ return 2
214
+
215
+
216
+ @app.command()
217
+ def umount(
218
+ kw: Annotated[
219
+ str,
220
+ typer.Argument(
221
+ help="Device keyword.",
222
+ autocompletion=_complete_umount_kw,
223
+ callback=_require_nonempty,
224
+ ),
225
+ ],
226
+ ) -> int:
227
+ import asyncio
228
+
229
+ env = lid.io.capture_environment()
230
+ return asyncio.run(_umount(kw, env=env))
lid/gio.py ADDED
@@ -0,0 +1,300 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Parse mount-style indentation based files."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from lid import types
13
+
14
+ TYPE_CHECKING = False
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+ from typing import Any, Protocol, cast, overload
18
+
19
+ class AsyncRunner(Protocol):
20
+ async def __call__(
21
+ self, bin_: str, /, *args: str, env_mods: dict[str, str] | None = None
22
+ ) -> str: ...
23
+
24
+ @overload
25
+ async def umount(
26
+ kw: str,
27
+ /,
28
+ *,
29
+ env: types.Environment,
30
+ runner: AsyncRunner,
31
+ ) -> None: ...
32
+ @overload
33
+ async def umount(
34
+ *,
35
+ mount: types.Mount,
36
+ env: types.Environment,
37
+ runner: AsyncRunner,
38
+ ) -> None: ...
39
+
40
+
41
+ __all__ = ["find_device_names", "mount", "umount"]
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+
46
+ # Return type dictated by use a completion suggester.
47
+ async def find_device_names(
48
+ kw: str, /, mounted_only: bool, env: types.Environment, runner: AsyncRunner
49
+ ) -> list[str]:
50
+ device_info = await _query_matching_device_info(
51
+ kw, include_details=False, env=env, runner=runner
52
+ )
53
+ if not mounted_only:
54
+ return [d["__name__"] for d in device_info]
55
+ return [d["__name__"] for d in device_info if any(k.startswith("Mount") for k in d)]
56
+
57
+
58
+ async def mount(kw: str, /, env: types.Environment, runner: AsyncRunner) -> types.Mount:
59
+ """Mount device.
60
+
61
+ Parameters
62
+ ----------
63
+ kw
64
+ (Partial) name of device as given in the top line of the gio output section.
65
+ """
66
+ device_info = await _query_matching_device_info(
67
+ kw, include_details=True, env=env, runner=runner
68
+ )
69
+ device = _extract_device(kw, device_info=device_info)
70
+
71
+ name, mount_id, is_mounted = device.name, device.id_, device.is_mounted
72
+ if is_mounted:
73
+ log.info("Device `%s` already mounted.", name)
74
+ else:
75
+ log.info("Mounting device `%s`.", name)
76
+ try:
77
+ await runner(env.gio_bin, "mount", "--device", mount_id)
78
+ except types.SubprocessError as err:
79
+ log.exception("Failed to mount device ID `%s`.", mount_id)
80
+ raise types.GioMountError(
81
+ f"failed to mount device with ID`{mount_id}`"
82
+ ) from err
83
+
84
+ try:
85
+ mp = await _find_mountpoint(mount_id, env=env, runner=runner)
86
+ except types.GioMountError as err:
87
+ log.exception("Found no mount point for `%s`.", name)
88
+ raise types.GioMountError(f"found no mount point for `{name}`") from err
89
+
90
+ return types.Mount(name=name, mountpoint=mp, triggered=mount_id is not None)
91
+
92
+
93
+ async def umount(
94
+ kw: str | None = None,
95
+ /,
96
+ *,
97
+ mount: types.Mount | None = None,
98
+ env: types.Environment,
99
+ runner: AsyncRunner,
100
+ ) -> None:
101
+ """Unmount device."""
102
+ if kw is not None:
103
+ device_info = await _query_matching_device_info(
104
+ kw, include_details=True, env=env, runner=runner
105
+ )
106
+ device = _extract_device(kw, device_info=device_info)
107
+
108
+ if not device.is_mounted:
109
+ log.info("Device `%s` not mounted.")
110
+ return
111
+ name, mount_id = device.name, device.id_
112
+
113
+ try:
114
+ mountpoint = await _find_mountpoint(mount_id, env=env, runner=runner)
115
+ except types.GioMountError as err:
116
+ log.exception("Found no mount point for `%s`.", name)
117
+ raise types.GioMountError(f"found no mount point for `{name}`") from err
118
+ else:
119
+ if TYPE_CHECKING:
120
+ mount = cast("types.Mount", mount)
121
+ mountpoint = mount.mountpoint
122
+
123
+ try:
124
+ await runner(env.gio_bin, "mount", "--unmount", mountpoint)
125
+ except types.SubprocessError as err:
126
+ raise types.GioMountError(f"failed to unmount `{mountpoint}`") from err
127
+
128
+
129
+ @dataclass(slots=True, kw_only=True)
130
+ class _Device:
131
+ name: str
132
+ id_: str
133
+ is_mounted: bool
134
+ data: dict[str, Any]
135
+
136
+
137
+ async def _query_matching_device_info(
138
+ kw: str, /, include_details: bool, env: types.Environment, runner: AsyncRunner
139
+ ) -> Sequence[dict[str, Any]]:
140
+ if not kw:
141
+ raise ValueError("empty keyword cannot be used for device identification")
142
+
143
+ if include_details:
144
+ gio_args = ("mount", "--list", "--detail")
145
+ else:
146
+ gio_args = ("mount", "--list")
147
+ try:
148
+ gio_out = await runner(env.gio_bin, *gio_args)
149
+ except types.SubprocessError as err:
150
+ raise types.GioMountError(
151
+ "failed to collect gio output for device discovery"
152
+ ) from err
153
+ try:
154
+ info = _parse_output(gio_out)
155
+ except types.GioParseError as err:
156
+ raise types.GioMountError(
157
+ "failed to parse gio output for device discovery"
158
+ ) from err
159
+
160
+ pattern = re.compile(kw, re.IGNORECASE)
161
+ return [
162
+ v
163
+ for k, v in info.items()
164
+ if k.startswith(("Volume", "Drive"))
165
+ and isinstance(v, dict)
166
+ and pattern.search(v.get("__name__", "")) is not None
167
+ ]
168
+
169
+
170
+ def _extract_device(kw: str, /, device_info: Sequence[dict[str, Any]]) -> _Device:
171
+ match device_info:
172
+ case []:
173
+ raise types.GioNotFoundError(f"found no device matching for `{kw}`")
174
+ case [{"__name__": name} as v]:
175
+ log.debug("Matched `%s` to full name `%s`.", kw, name)
176
+
177
+ is_mounted = any(k_.startswith("Mount") for k_ in v)
178
+ match v:
179
+ case {"ids": {"unix-device": str() as mount_id}}:
180
+ log.info("Found unix-device `%s`.")
181
+ case {"ids": {"uuid": str() as mount_id}}:
182
+ log.info("Found UUID `%s`.")
183
+ case _:
184
+ log.error("Failed to find mount ID in `%s`.", v)
185
+ raise types.GioNotFoundError(f"failed to find ID for `{name}`")
186
+ return _Device(name=name, id_=mount_id, is_mounted=is_mounted, data=v)
187
+ case [f, s, *ms]:
188
+ fn, sn, lms = f["__name__"], s["__name__"], len(ms)
189
+ raise types.GioNotFoundError(
190
+ f"found multiple matches for `{kw}`: `{fn}`, `{sn}`, and {lms} more."
191
+ )
192
+ case _:
193
+ raise types.GioNotFoundError("unsupported gio output structure")
194
+
195
+
196
+ async def _find_mountpoint(
197
+ id_: str, /, env: types.Environment, runner: AsyncRunner
198
+ ) -> str:
199
+ try:
200
+ # Requires device to be mounted.
201
+ # Need to the US locale as the output key are adapted to that setting.
202
+ gio_out = await runner(
203
+ env.gio_bin, "info", "-a", "local", id_, env_mods={"LANG": "en_US-UTF-8"}
204
+ )
205
+ except types.SubprocessError as err:
206
+ raise types.GioMountError(
207
+ "failed to collect gio output for mountpoint discovery"
208
+ ) from err
209
+ try:
210
+ gio_out = _parse_output(gio_out)
211
+ except types.GioParseError as err:
212
+ raise types.GioMountError(
213
+ "failed to parse gio output for path discovery"
214
+ ) from err
215
+
216
+ return gio_out["local path"]
217
+
218
+
219
+ def _parse_output(text: str, /, header_name: str = "__name__") -> dict[str, Any]:
220
+ content, stack = {}, []
221
+ for line in text.splitlines():
222
+ if (line := _parse_line(line)) is None:
223
+ continue
224
+
225
+ if not stack:
226
+ log.debug("Entering toplevel (indent %s).", line.indent)
227
+ content[line.key] = line.value
228
+ stack.append(_SubConfig(indent=line.indent, content=content))
229
+ continue
230
+
231
+ while len(stack) > 1 and line.indent < (prev_indent := stack[-1].indent):
232
+ log.debug("Leaving level (indent %s).", prev_indent)
233
+ stack.pop()
234
+ current = stack[-1]
235
+ if line.indent < current.indent:
236
+ raise types.GioParseError(
237
+ f"indent {line.indent} does not match any previous level"
238
+ )
239
+
240
+ if line.indent > current.indent:
241
+ log.debug("Entering level (indent %s).", line.indent)
242
+ # `current.content` is only empty in empty in the first round, wherein
243
+ # this case cannot occur.
244
+ prev_k, prev_v = next(reversed(current.content.items()))
245
+ if prev_v is None:
246
+ log.debug("Adding %s=%s.", line.key, line.value)
247
+ current.content[prev_k] = {line.key: line.value}
248
+ else:
249
+ log.debug("Adding existing value as header name.")
250
+ log.debug("Adding %s=%s.", line.key, line.value)
251
+ current.content[prev_k] = {header_name: prev_v, line.key: line.value}
252
+ stack.append(
253
+ _SubConfig(indent=line.indent, content=current.content[prev_k])
254
+ )
255
+ else:
256
+ log.debug("Adding %s=%s.", line.key, line.value)
257
+ current.content[line.key] = line.value
258
+
259
+ return content
260
+
261
+
262
+ @dataclass(slots=True, kw_only=True)
263
+ class _SubConfig:
264
+ indent: int
265
+ content: dict[str, Any]
266
+
267
+
268
+ @dataclass(slots=True, kw_only=True)
269
+ class _Line:
270
+ indent: int
271
+ key: str
272
+ value: str | None
273
+
274
+
275
+ _LEADING_WHITESP = re.compile(r"\s*(?=\S)")
276
+ _POS_QUOTED = re.compile(r"'?(?P<cnt>.?)'?")
277
+ _SPEC = re.compile(r"(?P<key>[A-Za-z][\w() -]*?)\s*[:=]\s*(?P<val>.*?)\s*")
278
+
279
+
280
+ def _parse_line(
281
+ line: str,
282
+ /,
283
+ ) -> _Line | None:
284
+ log.debug("Parsing `%s`.", line)
285
+ if (lwsp_match := _LEADING_WHITESP.match(line)) is None:
286
+ # Empty line or line containing only whitespace.
287
+ log.debug("Skipping empty line.")
288
+ return None
289
+
290
+ lwsp = lwsp_match.group()
291
+ indent = len(lwsp)
292
+ content = _POS_QUOTED.sub(r"\g<cnt>", line[lwsp_match.end() :])
293
+ log.debug("Extracted content: `%s` (indent: %s).", content, indent)
294
+
295
+ if (spec_match := _SPEC.fullmatch(content)) is None:
296
+ raise types.GioParseError("line not matching expected pattern: `%s`", content)
297
+
298
+ key, value = spec_match.group("key"), spec_match.group("val") or None
299
+ log.debug("Found key/value: `%s`=`%s`.", key, value)
300
+ return _Line(indent=indent, key=key, value=value)
lid/io.py ADDED
@@ -0,0 +1,78 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Functionality for IO."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ import msgspec
14
+
15
+ from lid import __name__ as pkg_name
16
+ from lid import types
17
+
18
+ TYPE_CHECKING = False
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Sequence
21
+ from typing import Literal, overload
22
+
23
+ @overload
24
+ def _find_binary(binary: str, /) -> str: ...
25
+ @overload
26
+ def _find_binary(binary: str, /, accept_failure: Literal[True]) -> str | None: ...
27
+
28
+
29
+ __all__ = ["capture_environment"]
30
+
31
+
32
+ def _getenv[T](env_var: str, /, default: T) -> T:
33
+ if (v := os.getenv(env_var)) is None:
34
+ return default
35
+ return type(default)(v)
36
+
37
+
38
+ def _find_binary(binary: str, /, accept_failure: bool = False) -> str | None:
39
+ if (abs_path := shutil.which(binary)) is None:
40
+ if accept_failure:
41
+ return None
42
+ raise FileNotFoundError(f"executable '{binary}' not found")
43
+ return abs_path
44
+
45
+
46
+ def capture_environment() -> types.Environment:
47
+ """Capture environment."""
48
+ user_id = os.getuid()
49
+ sys_runtime_dir = Path("/", "run", "user", str(user_id))
50
+ runtime_dir = _getenv("XDG_RUNTIME_DIR", sys_runtime_dir) / pkg_name
51
+
52
+ try:
53
+ gio_bin = _find_binary(_getenv("LID_GIO", "gio"))
54
+ except FileNotFoundError as err:
55
+ raise types.EnvCaptureError("gio binary not found") from err
56
+
57
+ try:
58
+ log_level: int | None = int(os.getenv("LID_LOGLEVEL", ""))
59
+ except ValueError:
60
+ log_level = None
61
+
62
+ return types.Environment(
63
+ runtime_dir=runtime_dir, gio_bin=gio_bin, log_level=log_level
64
+ )
65
+
66
+
67
+ # Intentionally returning a list to allow execution in a separate thread.
68
+ def read_mount_markers(env: types.Environment) -> Sequence[tuple[Path, types.Mount]]:
69
+ decode = msgspec.json.Decoder(type=types.Mount).decode
70
+
71
+ markers = []
72
+ for p in env.runtime_dir.iterdir():
73
+ if not p.is_file():
74
+ continue
75
+
76
+ with contextlib.suppress(msgspec.DecodeError, msgspec.ValidationError):
77
+ markers.append((p, decode(p.read_bytes())))
78
+ return markers
lid/py.typed ADDED
File without changes
lid/subprocess.py ADDED
@@ -0,0 +1,70 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Functionality for calling subprocesses."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextvars
10
+ import os
11
+
12
+ from lid import types
13
+
14
+ TYPE_CHECKING = False
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable, Mapping
17
+ from typing import Any
18
+
19
+ __all__ = ["raise_on_stderr", "run"]
20
+
21
+ RUNNING = contextvars.ContextVar[str]("_RUNNING")
22
+
23
+
24
+ def raise_on_stderr(stderr: bytes, /) -> None:
25
+ if stderr:
26
+ running = RUNNING.get()
27
+ err = stderr.decode("utf-8")
28
+ raise types.SubprocessError(f"{running} generated error message:\n{err}")
29
+
30
+
31
+ async def run(
32
+ bin_: str,
33
+ *args: str,
34
+ handle_stderr: Callable[[bytes], None] = raise_on_stderr,
35
+ env_mods: Mapping[str, str] | None = None,
36
+ ) -> str:
37
+ """Run command and return stdout content."""
38
+ proc_kwargs: dict[str, Any] = (
39
+ {"env": os.environ | env_mods} if env_mods is not None else {}
40
+ )
41
+ proc = await asyncio.create_subprocess_exec(
42
+ bin_,
43
+ *args,
44
+ stdin=asyncio.subprocess.DEVNULL,
45
+ stdout=asyncio.subprocess.PIPE,
46
+ stderr=asyncio.subprocess.PIPE,
47
+ **proc_kwargs,
48
+ )
49
+ token = RUNNING.set(f"`{bin_} {' '.join(args)}`")
50
+ try:
51
+ stdout, stderr = await proc.communicate()
52
+ except asyncio.CancelledError:
53
+ proc.terminate()
54
+ RUNNING.reset(token)
55
+ raise
56
+
57
+ if proc.returncode != 0:
58
+ running = RUNNING.get()
59
+ RUNNING.reset(token)
60
+ raise types.SubprocessError(
61
+ f"{running} exited with return code {proc.returncode}:"
62
+ f" {stderr.decode('utf-8')}"
63
+ )
64
+ try:
65
+ if stderr:
66
+ handle_stderr(stderr)
67
+ stdout = stdout.decode("utf-8")
68
+ finally:
69
+ RUNNING.reset(token)
70
+ return stdout
lid/types.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Shared data structures."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import msgspec
11
+
12
+ TYPE_CHECKING = False
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+
17
+ class LidError(RuntimeError):
18
+ """Exception raised by lid functionality."""
19
+
20
+
21
+ class SubprocessError(LidError):
22
+ """Raised when a subprocess concludes with an error."""
23
+
24
+
25
+ class EnvCaptureError(LidError):
26
+ """Exception raised when capturing the environment fails."""
27
+
28
+
29
+ class GioParseError(LidError):
30
+ """Raised when gio CLI output cannot be parsed."""
31
+
32
+
33
+ class GioMountError(LidError):
34
+ """Exception raised when (un)mounting device fails."""
35
+
36
+
37
+ class GioNotFoundError(GioMountError):
38
+ """Exception raised when (un)mounting device fails due to missing device."""
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class Environment:
43
+ """Captures environment parameters."""
44
+
45
+ runtime_dir: Path
46
+
47
+ log_level: int | None
48
+
49
+ gio_bin: str
50
+
51
+
52
+ class Mount(msgspec.Struct, forbid_unknown_fields=True, kw_only=True):
53
+ """Summary of mount attempt/action.
54
+
55
+ Parameters
56
+ ----------
57
+ name
58
+ Device ID as displayed by `gio mount --list`.
59
+ mountpoint
60
+ Mountpoint (local directory logically linked to device file system).
61
+ triggered
62
+ True if the attempt actually mounted the device.
63
+ """
64
+
65
+ name: str
66
+ mountpoint: str
67
+
68
+ triggered: bool
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: lid-cli
3
+ Version: 0.1
4
+ Summary: Project Lid Cli.
5
+ Author: Christian Heinze
6
+ License-Expression: MIT
7
+ License-File: LICENSES/MIT.txt
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Utilities
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: msgspec>=0.21
17
+ Requires-Dist: rich>=14.3
18
+ Requires-Dist: typer>=0.24
19
+ Requires-Python: >=3.14
20
+ Project-URL: Repository, https://codeberg.org/christianheinze/lid-cli
21
+ Description-Content-Type: text/markdown
22
+
23
+ # lid-cli
24
+
25
+ To find device, run `gio mount --list --detail` and select ID in the first line.
26
+
27
+ ## TODO
28
+
29
+ - More error handling and testing.
30
+ - Build CLI with mount and umount commands.
31
+ - Possibly write target to disk in a small file for umount to read from.
@@ -0,0 +1,13 @@
1
+ lid/__init__.py,sha256=E7fcaSN9c1x0ewb-WO6q5EPe6eC8DJ45i4kvTaoocHk,658
2
+ lid/__main__.py,sha256=Haato9KRuIG0rhGdZJi2baPNDnFPCbzUddpq-KIndP0,172
3
+ lid/cli.py,sha256=9SVZqEDCilJ2M2ifEf6Ylj6wYCqTu_GbcPTDoFmH7hA,7071
4
+ lid/gio.py,sha256=lge3vnLyErCr_7dGCFcR3G_qEg1t8T_k29saA7UaXDU,9962
5
+ lid/io.py,sha256=q3qaD47lwjNZ0b2mKiG1926ig5-7NQhf7ehuCGgyAT4,2154
6
+ lid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ lid/subprocess.py,sha256=28HPv8dhwL6jp5orCjYmp6jKTTSlYD1o0Zny7A7G1ao,1831
8
+ lid/types.py,sha256=dAWbyILef7wqfPe9JguZ9PwjR6tJGO7BfS5gCE6jeTs,1430
9
+ lid_cli-0.1.dist-info/licenses/LICENSES/MIT.txt,sha256=PZVKOoRAo04wioe5oVvpvP925EYXpkOr2y2MMmXy6hY,1072
10
+ lid_cli-0.1.dist-info/WHEEL,sha256=fWriCkzqm-pffF5af4gJC9iI5FMFaJTuN9UxxxzOmdY,81
11
+ lid_cli-0.1.dist-info/entry_points.txt,sha256=8Vj0fd_agODJUYqLNItt9SpHTCyqWmQZ9EALRBpawk0,37
12
+ lid_cli-0.1.dist-info/METADATA,sha256=6Z94Q8zkHye81bDAeNqJmyWoW9DgM_C_bt_jzVAyGGE,969
13
+ lid_cli-0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.14
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ lid = lid.cli:app
3
+
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright Christian Heinze
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.