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 +25 -0
- lid/__main__.py +9 -0
- lid/cli.py +230 -0
- lid/gio.py +300 -0
- lid/io.py +78 -0
- lid/py.typed +0 -0
- lid/subprocess.py +70 -0
- lid/types.py +68 -0
- lid_cli-0.1.dist-info/METADATA +31 -0
- lid_cli-0.1.dist-info/RECORD +13 -0
- lid_cli-0.1.dist-info/WHEEL +4 -0
- lid_cli-0.1.dist-info/entry_points.txt +3 -0
- lid_cli-0.1.dist-info/licenses/LICENSES/MIT.txt +9 -0
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
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,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.
|