ybox 0.9.8__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.
Files changed (76) hide show
  1. ybox/__init__.py +2 -0
  2. ybox/cmd.py +307 -0
  3. ybox/conf/completions/ybox.fish +93 -0
  4. ybox/conf/distros/arch/add-gpg-key.sh +29 -0
  5. ybox/conf/distros/arch/distro.ini +192 -0
  6. ybox/conf/distros/arch/init-base.sh +10 -0
  7. ybox/conf/distros/arch/init-user.sh +35 -0
  8. ybox/conf/distros/arch/init.sh +82 -0
  9. ybox/conf/distros/arch/list_fmt_long.py +76 -0
  10. ybox/conf/distros/arch/pkgdeps.py +276 -0
  11. ybox/conf/distros/deb-generic/check-package.sh +77 -0
  12. ybox/conf/distros/deb-generic/distro.ini +190 -0
  13. ybox/conf/distros/deb-generic/fetch-gpg-key-id.sh +30 -0
  14. ybox/conf/distros/deb-generic/init-base.sh +11 -0
  15. ybox/conf/distros/deb-generic/init-user.sh +3 -0
  16. ybox/conf/distros/deb-generic/init.sh +136 -0
  17. ybox/conf/distros/deb-generic/list_fmt_long.py +114 -0
  18. ybox/conf/distros/deb-generic/pkgdeps.py +208 -0
  19. ybox/conf/distros/deb-oldstable/distro.ini +21 -0
  20. ybox/conf/distros/deb-stable/distro.ini +21 -0
  21. ybox/conf/distros/supported.list +5 -0
  22. ybox/conf/distros/ubuntu2204/distro.ini +21 -0
  23. ybox/conf/distros/ubuntu2404/distro.ini +21 -0
  24. ybox/conf/profiles/apps.ini +26 -0
  25. ybox/conf/profiles/basic.ini +310 -0
  26. ybox/conf/profiles/dev.ini +25 -0
  27. ybox/conf/profiles/games.ini +39 -0
  28. ybox/conf/resources/entrypoint-base.sh +170 -0
  29. ybox/conf/resources/entrypoint-common.sh +23 -0
  30. ybox/conf/resources/entrypoint-cp.sh +32 -0
  31. ybox/conf/resources/entrypoint-root.sh +20 -0
  32. ybox/conf/resources/entrypoint-user.sh +21 -0
  33. ybox/conf/resources/entrypoint.sh +249 -0
  34. ybox/conf/resources/prime-run +13 -0
  35. ybox/conf/resources/run-in-dir +60 -0
  36. ybox/conf/resources/run-user-bash-cmd +14 -0
  37. ybox/config.py +255 -0
  38. ybox/env.py +205 -0
  39. ybox/filelock.py +77 -0
  40. ybox/migrate/0.9.0-0.9.7:0.9.8.py +33 -0
  41. ybox/pkg/__init__.py +0 -0
  42. ybox/pkg/clean.py +33 -0
  43. ybox/pkg/info.py +40 -0
  44. ybox/pkg/inst.py +638 -0
  45. ybox/pkg/list.py +191 -0
  46. ybox/pkg/mark.py +68 -0
  47. ybox/pkg/repair.py +150 -0
  48. ybox/pkg/repo.py +251 -0
  49. ybox/pkg/search.py +52 -0
  50. ybox/pkg/uninst.py +92 -0
  51. ybox/pkg/update.py +56 -0
  52. ybox/print.py +121 -0
  53. ybox/run/__init__.py +0 -0
  54. ybox/run/cmd.py +54 -0
  55. ybox/run/control.py +102 -0
  56. ybox/run/create.py +1116 -0
  57. ybox/run/destroy.py +64 -0
  58. ybox/run/graphics.py +367 -0
  59. ybox/run/logs.py +57 -0
  60. ybox/run/ls.py +64 -0
  61. ybox/run/pkg.py +445 -0
  62. ybox/schema/0.9.1-added.sql +27 -0
  63. ybox/schema/0.9.6-added.sql +18 -0
  64. ybox/schema/init.sql +39 -0
  65. ybox/schema/migrate/0.9.0:0.9.1.sql +42 -0
  66. ybox/schema/migrate/0.9.1:0.9.2.sql +8 -0
  67. ybox/schema/migrate/0.9.2:0.9.3.sql +2 -0
  68. ybox/schema/migrate/0.9.5:0.9.6.sql +2 -0
  69. ybox/state.py +914 -0
  70. ybox/util.py +351 -0
  71. ybox-0.9.8.dist-info/LICENSE +19 -0
  72. ybox-0.9.8.dist-info/METADATA +533 -0
  73. ybox-0.9.8.dist-info/RECORD +76 -0
  74. ybox-0.9.8.dist-info/WHEEL +5 -0
  75. ybox-0.9.8.dist-info/entry_points.txt +8 -0
  76. ybox-0.9.8.dist-info/top_level.txt +1 -0
ybox/util.py ADDED
@@ -0,0 +1,351 @@
1
+ """
2
+ Common utility classes and methods used by the scripts.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from configparser import BasicInterpolation, ConfigParser, Interpolation
12
+ from dataclasses import dataclass, field
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+ from typing import Any, Iterable, Optional, Sequence
16
+
17
+ from simple_term_menu import TerminalMenu # type: ignore
18
+ from tabulate import tabulate
19
+
20
+ from ybox import __version__ as product_version
21
+
22
+ from .cmd import build_shell_command, get_ybox_state
23
+ from .config import Consts, StaticConfiguration
24
+ from .env import Environ, PathName
25
+ from .print import fgcolor as fg
26
+ from .print import get_terminal_width, print_error, print_warn
27
+
28
+
29
+ class EnvInterpolation(BasicInterpolation):
30
+ """
31
+ Substitute environment variables in the values using 'os.path.expandvars'.
32
+ In addition, a special substitution of ${NOW:<fmt>} is supported to substitute the
33
+ current time (captured by InitNow above) in the 'datetime.strftime' format.
34
+
35
+ This class extends `BasicInterpolation` hence the `%(.)s` syntax can be used to expand other
36
+ keys in the same section or the `[DEFAULT]` section in the `before_get`. If a bare '%' is
37
+ required in the value, then it should be escaped with a '%' i.e. use '%%' for a single '%'.
38
+ Note that the environment variable and NOW substitution is done in the `before_read` phase
39
+ before any `BasicInterpolation` is done, so any '%' characters in those environment variable
40
+ or ${NOW:...} expansions should not be escaped.
41
+
42
+ If 'skip_expansion' is specified in initialization to a non-empty list, then no
43
+ environment variable substitution is performed for those sections but the
44
+ ${NOW:...} substitution is still performed.
45
+ """
46
+
47
+ _NOW_RE = re.compile(r"\${NOW:([^}]*)}")
48
+
49
+ def __init__(self, env: Environ, skip_expansion: list[str]):
50
+ super().__init__()
51
+ self._skip_expansion = skip_expansion
52
+ # for the NOW substitution
53
+ self._now = env.now
54
+
55
+ # override before_read rather than before_get because expanded vars are needed when writing
56
+ # into the state.db database too
57
+ def before_read(self, parser, section: str, option: str, value: str) -> str: # type: ignore
58
+ """Override before_read to substitute environment variables and ${NOW...} pattern.
59
+ This method is overridden rather than before_get because expanded variables are
60
+ also required when writing the configuration into the state.db database."""
61
+ if not value:
62
+ return value
63
+ if section not in self._skip_expansion:
64
+ value = os.path.expandvars(value)
65
+ # replace ${NOW:...} pattern with appropriately formatted datetime string
66
+ return self._NOW_RE.sub(lambda mt: self._now.strftime(mt.group(1)), value)
67
+
68
+
69
+ def resolve_inc_path(inc: str, src: PathName) -> PathName:
70
+ """resolve `include` path specified relative to a given source, or as an absolute string"""
71
+ return Path(inc) if os.path.isabs(inc) else src.parent.joinpath(inc) # type: ignore
72
+
73
+
74
+ # read the ini file, recursing into the includes to build the final dictionary
75
+ def config_reader(conf_file: PathName, interpolation: Optional[Interpolation],
76
+ top_level: Optional[PathName] = None) -> ConfigParser:
77
+ """
78
+ Read the container configuration INI file, recursing into the includes to build the final
79
+ dictionary having the sections with corresponding key-value pairs.
80
+
81
+ :param conf_file: the configuration file to be read as a `Path` or resource file from
82
+ importlib (`Traversable`)
83
+ :param interpolation: if provided then used for value interpolation
84
+ :param top_level: the top-level configuration file; don't pass this when calling
85
+ externally (or set it the same as `conf_file` argument)
86
+ :return: instance of `ConfigParser` built after parsing the given file as
87
+ well as any includes recursively
88
+ """
89
+ if not conf_file.is_file():
90
+ if top_level:
91
+ raise FileNotFoundError(f"Config file '{conf_file}' among the includes of "
92
+ f"'{top_level}' does not exist or not a file")
93
+ raise FileNotFoundError(f"Config file '{conf_file}' does not exist or not a file")
94
+ with conf_file.open("r", encoding="utf-8") as conf_fd:
95
+ config = ini_file_reader(conf_fd, interpolation)
96
+ if not top_level:
97
+ top_level = conf_file
98
+ if not (includes := config.get("base", "includes", fallback="")):
99
+ return config
100
+ for include in includes.split(","):
101
+ if not (include := include.strip()):
102
+ continue
103
+ # relative paths inside an include file (e.g. scripts in distro.ini) are relative
104
+ # to the including file and not the top-level parent
105
+ inc_file = resolve_inc_path(include, conf_file)
106
+ inc_conf = config_reader(inc_file, interpolation, top_level)
107
+ # disable interpolation for inc_conf after read else it can apply again when assigning
108
+ # pylint: disable=protected-access
109
+ inc_conf._interpolation = Interpolation() # type: ignore
110
+ for section in inc_conf.sections():
111
+ if not config.has_section(section):
112
+ config[section] = inc_conf[section]
113
+ else:
114
+ conf_section = config[section]
115
+ inc_section = inc_conf[section]
116
+ for key in inc_section:
117
+ if key not in conf_section:
118
+ conf_section[key] = inc_section[key]
119
+ return config
120
+
121
+
122
+ def ini_file_reader(fd: Iterable[str], interpolation: Optional[Interpolation],
123
+ case_sensitive: bool = True) -> ConfigParser:
124
+ """
125
+ Read an INI file from a given file handle. It applies some basic rules that are used
126
+ for all ybox configurations like allowing no values, only '=' as delimiters and
127
+ case-sensitive keys.
128
+
129
+ :param fd: file handle for the INI format data
130
+ :param interpolation: if provided then used for value interpolation
131
+ :param case_sensitive: if True then keys are case-sensitive (default) else case-insensitive
132
+ :return: instance of `ConfigParser` built after parsing the given file
133
+ """
134
+ config = ConfigParser(allow_no_value=True, interpolation=interpolation, delimiters="=")
135
+ if case_sensitive:
136
+ config.optionxform = str # type: ignore
137
+ config.read_file(fd)
138
+ return config
139
+
140
+
141
+ def copy_file(src: PathName, dest: str, permissions: Optional[int] = None) -> None:
142
+ """
143
+ Copy a given source file (can be on filesystem or package resource) to destination path
144
+ overwriting if it exists, and with given optional permissions. If `permissions` is not provided
145
+ then this method tries to copy the permissions of the source to the destination (thus ignoring
146
+ the `umask`), so is similar to `cp --preserve=mode` for that case. The size of the file should
147
+ not be large since this method loads the entire `src` file as bytes then writes to `dest`.
148
+
149
+ :param src: the source file or package resource
150
+ :param dest: destination file path
151
+ :param permissions: optional file permissions as an integer as accepted by :func:`os.chmod`,
152
+ defaults to None
153
+ """
154
+ with open(dest, "wb") as dest_fd:
155
+ dest_fd.write(src.read_bytes())
156
+ if permissions is not None:
157
+ os.chmod(dest, permissions)
158
+ elif hasattr(src, "stat"): # copy the permissions
159
+ # pyright does not check hasattr, hence the "type: ignore" instead of artificial TypeGuards
160
+ if hasattr(src, "resolve"):
161
+ src = src.resolve() # type: ignore
162
+ perms = stat.S_IMODE(src.stat().st_mode) # type: ignore
163
+ os.chmod(dest, perms)
164
+
165
+
166
+ def copy_ybox_scripts_to_container(conf: StaticConfiguration, distro_config: ConfigParser) -> None:
167
+ """
168
+ Copy ybox setup scripts to local directory mounted on container.
169
+
170
+ :param conf: the :class:`StaticConfiguration` for the container
171
+ :param distro_config: an object of :class:`ConfigParser` from parsing the Linux
172
+ distribution's `distro.ini`
173
+ """
174
+ env = conf.env
175
+ # copy the common scripts
176
+ for script in Consts.resource_scripts():
177
+ path = env.search_config_path(f"resources/{script}", only_sys_conf=True)
178
+ copy_file(path, f"{conf.scripts_dir}/{script}", permissions=0o755)
179
+ # also copy distribution specific scripts
180
+ base_section = distro_config["base"]
181
+ if scripts := base_section.get("scripts"):
182
+ for script in scripts.split(","):
183
+ script = script.strip()
184
+ path = env.search_config_path(conf.distribution_config(conf.distribution, script),
185
+ only_sys_conf=True)
186
+ copy_file(path, f"{conf.scripts_dir}/{os.path.basename(script)}", permissions=0o644)
187
+ # finally copy the ybox python module which may be used by distribution scripts
188
+ src_dir = files("ybox")
189
+ dest_dir = f"{conf.scripts_dir}/ybox"
190
+ os.makedirs(dest_dir, exist_ok=True)
191
+ # allow for read/execute permissions for all since non-root user needs access with docker
192
+ os.chmod(dest_dir, mode=0o755)
193
+ for resource in src_dir.iterdir():
194
+ if resource.is_file():
195
+ copy_file(resource, f"{dest_dir}/{resource.name}", permissions=0o644)
196
+
197
+
198
+ def write_ybox_version(conf: StaticConfiguration) -> None:
199
+ """
200
+ Write the version file having the current product version to container scripts directory.
201
+
202
+ :param conf: the :class:`StaticConfiguration` for the container
203
+ """
204
+ version_file = f"{conf.scripts_dir}/version"
205
+ with open(version_file, "w", encoding="utf-8") as version_fd:
206
+ version_fd.write(product_version)
207
+
208
+
209
+ def get_ybox_version(conf: StaticConfiguration) -> str:
210
+ """
211
+ Get the product version string recorded in the container or empty if no version was recorded.
212
+
213
+ :param conf: the :class:`StaticConfiguration` for the container
214
+ :return: the version recorded in the container as a string, or empty if not present
215
+ """
216
+ version_file = f"{conf.scripts_dir}/version"
217
+ if os.access(version_file, os.R_OK):
218
+ with open(version_file, "r", encoding="utf-8") as fd:
219
+ return fd.read().strip()
220
+ return ""
221
+
222
+
223
+ def wait_for_ybox_container(docker_cmd: str, conf: StaticConfiguration) -> None:
224
+ """
225
+ Wait for container created with `create.start_container` to finish all its initialization.
226
+ This depends on the specific entrypoint script used by `create.start_container` to write
227
+ and update its status in a file bind mounted in a host directory readable from outside.
228
+ This waits for a maximum of 600 seconds which is hard-coded.
229
+
230
+ :param docker_cmd: the podman/docker executable to use
231
+ :param conf: the :class:`StaticConfiguration` for the container
232
+ """
233
+ sys.stdout.flush()
234
+ box_name = conf.box_name
235
+ max_wait_secs = 600
236
+ status_line = "" # keeps the last valid line read from status file
237
+ with open(conf.status_file, "r", encoding="utf-8") as status_fd:
238
+
239
+ def read_lines() -> bool:
240
+ """
241
+ Read status file, clear it if container has finished starting or stopping and return
242
+ True for that case else return False.
243
+ """
244
+ nonlocal status_line
245
+ while line := status_fd.readline():
246
+ status_line = line
247
+ if status_line.strip() in ("started", "stopped"):
248
+ # clear the status file and return
249
+ truncate_file(conf.status_file)
250
+ return True
251
+ print(line, end="") # line already includes the terminating newline
252
+ return False
253
+
254
+ for _ in range(max_wait_secs):
255
+ # check the container status first which may be running or stopping
256
+ # in which case sleep and retry (if stopped, then read_lines should succeed)
257
+ if get_ybox_state(docker_cmd, box_name, expected_states=("running", "stopping")):
258
+ if read_lines():
259
+ return
260
+ else:
261
+ time.sleep(1) # wait for sometime for file write to become visible
262
+ if read_lines():
263
+ return
264
+ print_error("FAILED waiting for container to be ready (last status: "
265
+ f"{status_line}).\nCheck 'ybox-logs {box_name}' for more details.")
266
+ sys.exit(1)
267
+ # using simple poll per second rather than inotify or similar because the
268
+ # initialization can take a good amount of time and second granularity is enough
269
+ time.sleep(1)
270
+ # reading did not end after max_wait_secs
271
+ print_error(f"TIMED OUT waiting for ready container after {max_wait_secs}secs (last status: "
272
+ f"{status_line}).\nCheck 'ybox-logs -f {box_name}' for more details.")
273
+ sys.exit(1)
274
+
275
+
276
+ def truncate_file(file: str) -> None:
277
+ """truncate an existing file"""
278
+ with open(file, "a", encoding="utf-8") as file_fd:
279
+ file_fd.truncate(0)
280
+
281
+
282
+ def check_package(docker_cmd: str, check_cmd: str, package: str,
283
+ container_name: str) -> tuple[int, list[str]]:
284
+ """
285
+ Check if a given package is installed in a container, or available in package repositories
286
+ and return the list of matching packages.
287
+
288
+ :param docker_cmd: the podman/docker executable to use
289
+ :param check_cmd: the command used to check the existence of the package
290
+ :param package: name of the package to check
291
+ :param container_name: name of the container
292
+ and name of matching package names which can be different for a virtual package
293
+ """
294
+ check_result = subprocess.run(build_shell_command(
295
+ docker_cmd, container_name, check_cmd.format(package=package), enable_pty=False),
296
+ check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
297
+ output = check_result.stdout.decode("utf-8").splitlines()
298
+ return (check_result.returncode, output) if output else (1, output)
299
+
300
+
301
+ def select_item_from_menu(items: list[str]) -> Optional[str]:
302
+ """
303
+ Display a list of items on terminal and allow user to select an item from it interactively
304
+ using arrow keys and all.
305
+
306
+ :param items: list of items to be displayed
307
+ :return: the chosen item, or None if user aborted the selection
308
+ """
309
+ terminal_menu = TerminalMenu(items,
310
+ status_bar="Press <Enter> to select, <Esc> to exit")
311
+ selection = terminal_menu.show()
312
+ if isinstance(selection, int):
313
+ return items[selection]
314
+ print_warn("Aborted selection")
315
+ return None
316
+
317
+
318
+ @dataclass
319
+ class FormatTable:
320
+ """
321
+ Format a given table of values as a table appropriate for display on a terminal.
322
+
323
+ Attributes:
324
+ table: an `Iterable` of `Iterable` values as accepted by :func:`tabulate.tabulate`
325
+ headers: a `Sequence` of header names corresponding to each column in the `table`
326
+ colors: a `Sequence` of color strings (e.g. :func:`fgcolor.red`) for each of the columns
327
+ fmt: formatting style of the table (e.g. `rounded_grid`) as accepted by `tabulate.tabulate`
328
+ col_width_ratios: ratios of widths of the columns as an `Iterable` of floats; the length
329
+ of this should match that of `table` and `headers_with_colors`
330
+ max_col_widths: calculated maximum widths of the columns from `col_width_ratios` as a
331
+ `Sequence` of integers
332
+ """
333
+ table: Iterable[Iterable[Any]]
334
+ headers: Sequence[str]
335
+ colors: Sequence[str]
336
+ fmt: str
337
+ col_width_ratios: Iterable[float]
338
+ max_col_widths: Sequence[int] = field(init=False)
339
+
340
+ def __post_init__(self):
341
+ # reduce available width for borders and padding
342
+ available_width = get_terminal_width() - len(self.headers) * 4 - 1
343
+ ratio_sum = sum(self.col_width_ratios)
344
+ self.max_col_widths = [int(r * available_width / ratio_sum) for r in self.col_width_ratios]
345
+
346
+ def show(self) -> str:
347
+ """return formatted table as a string appropriate for display in the current terminal"""
348
+ table = ((f"{c}{v}{fg.reset}" for v, c in zip(line, self.colors)) for line in self.table)
349
+ headers = [f"{c}{h}{fg.reset}" for h, c in zip(self.headers, self.colors)]
350
+ return tabulate(table, headers, tablefmt=self.fmt, disable_numparse=True,
351
+ maxcolwidths=self.max_col_widths)
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2024-2025 Sumedh Wale and contributors
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.