offspot-config 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- offspot_config/__init__.py +0 -0
- offspot_config/builder.py +416 -0
- offspot_config/catalog.py +37 -0
- offspot_config/constants.py +56 -0
- offspot_config/file.py +123 -0
- offspot_config/inputs.py +183 -0
- offspot_config/oci_images.py +48 -0
- offspot_config/packages.py +168 -0
- offspot_config/utils/__init__.py +0 -0
- offspot_config/utils/download.py +52 -0
- offspot_config/utils/misc.py +385 -0
- offspot_config/utils/yaml.py +49 -0
- offspot_config/zim.py +58 -0
- offspot_config-1.3.0.dist-info/METADATA +254 -0
- offspot_config-1.3.0.dist-info/RECORD +29 -0
- offspot_config-1.3.0.dist-info/WHEEL +4 -0
- offspot_config-1.3.0.dist-info/entry_points.txt +8 -0
- offspot_config-1.3.0.dist-info/licenses/LICENSE +674 -0
- offspot_runtime/__about__.py +1 -0
- offspot_runtime/__init__.py +0 -0
- offspot_runtime/ap.py +597 -0
- offspot_runtime/checks.py +559 -0
- offspot_runtime/configlib.py +176 -0
- offspot_runtime/containers.py +98 -0
- offspot_runtime/dnsmasqspoof.py +123 -0
- offspot_runtime/ethernet.py +188 -0
- offspot_runtime/fromfile.py +279 -0
- offspot_runtime/hostname.py +84 -0
- offspot_runtime/timezone.py +70 -0
|
File without changes
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from offspot_config.constants import CONTENT_TARGET_PATH
|
|
6
|
+
from offspot_config.inputs import BaseConfig, BlockStr, FileConfig
|
|
7
|
+
from offspot_config.oci_images import OCIImage
|
|
8
|
+
from offspot_config.packages import AppPackage, FilesPackage, ZimPackage
|
|
9
|
+
from offspot_config.utils.yaml import yaml_dump
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigBuilder:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
base: BaseConfig,
|
|
17
|
+
name: str = "My Offspot",
|
|
18
|
+
tld: str | None = "offspot",
|
|
19
|
+
domain: str | None = "my-offspot",
|
|
20
|
+
ssid: str | None = "my-offspot",
|
|
21
|
+
passphrase: str | None = None,
|
|
22
|
+
timezone: str | None = "UTC", # noqa: ARG002
|
|
23
|
+
as_gateway: bool | None = False,
|
|
24
|
+
write_config: bool | None = False,
|
|
25
|
+
):
|
|
26
|
+
self.name = name
|
|
27
|
+
self.config: dict[str, Any] = {
|
|
28
|
+
"base": {"source": base.source, "rootfs_size": base.rootfs_size},
|
|
29
|
+
"output": {"size": "auto"},
|
|
30
|
+
"oci_images": set(),
|
|
31
|
+
"files": [],
|
|
32
|
+
"write_config": write_config,
|
|
33
|
+
"offspot": {
|
|
34
|
+
"containers": {"name": "offspot", "services": {}},
|
|
35
|
+
"ap": {
|
|
36
|
+
"domain": domain,
|
|
37
|
+
"tld": tld,
|
|
38
|
+
"ssid": ssid,
|
|
39
|
+
"passphrase": passphrase,
|
|
40
|
+
"as-gateway": as_gateway,
|
|
41
|
+
},
|
|
42
|
+
"ethernet": {"type": "dhcp"},
|
|
43
|
+
"timezone": "UTC",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# whether dashboard will offer downloads for ZIM files
|
|
48
|
+
self.dashboard_offers_zim_downloads = True
|
|
49
|
+
# every card the dashboard will display
|
|
50
|
+
self.dashboard_entries = []
|
|
51
|
+
# all files that will be downloaded and places onto disk
|
|
52
|
+
self.file_entries = []
|
|
53
|
+
# domain of services that must be reversed to (all but special cases)
|
|
54
|
+
self.reversed_services = set()
|
|
55
|
+
# domain: folder map of virtual services serving only files
|
|
56
|
+
self.files_mapping = {}
|
|
57
|
+
|
|
58
|
+
self.with_kiwixserve: bool = False
|
|
59
|
+
self.with_files: bool = False
|
|
60
|
+
self.with_reverseproxy: bool = False
|
|
61
|
+
self.with_dashboard: bool = False
|
|
62
|
+
self.with_captive_portal: bool = False
|
|
63
|
+
self.with_hwclock: bool = False
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def compose(self):
|
|
67
|
+
return self.config["offspot"]["containers"]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def fqdn(self):
|
|
71
|
+
return (
|
|
72
|
+
f"{self.config['offspot']['ap']['domain']}."
|
|
73
|
+
f"{self.config['offspot']['ap']['tld']}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def update_offspot_config(self, **kwargs):
|
|
77
|
+
self.config["offspot"].update(kwargs)
|
|
78
|
+
|
|
79
|
+
# def add_package(self, ident: str):
|
|
80
|
+
# try:
|
|
81
|
+
# prefix, ident = ident.split(":", 1)
|
|
82
|
+
# except Exception as exc:
|
|
83
|
+
# raise ValueError(
|
|
84
|
+
# f"Invalid prefix:ident value for package: {ident}"
|
|
85
|
+
# ) from exc
|
|
86
|
+
|
|
87
|
+
# if prefix == "zim":
|
|
88
|
+
# return self.add_zim(ident)
|
|
89
|
+
# if prefix == "app":
|
|
90
|
+
# return self.add_app(ident)
|
|
91
|
+
# if prefix == "file":
|
|
92
|
+
# return self.add_files_package(ident)
|
|
93
|
+
|
|
94
|
+
# raise ValueError(f"Invalid package prefix: {prefix}")
|
|
95
|
+
|
|
96
|
+
def set_output_size(self, size: int):
|
|
97
|
+
self.config["output"]["size"] = size
|
|
98
|
+
|
|
99
|
+
def add_dashboard(self, *, allow_zim_downloads: bool | None = False):
|
|
100
|
+
self.dashboard_offers_zim_downloads = allow_zim_downloads
|
|
101
|
+
if self.with_dashboard:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
self.with_dashboard = True
|
|
105
|
+
|
|
106
|
+
image = OCIImage(
|
|
107
|
+
ident="ghcr.io/offspot/dashboard:1.0",
|
|
108
|
+
filesize=119941120,
|
|
109
|
+
fullsize=119838811,
|
|
110
|
+
)
|
|
111
|
+
self.config["oci_images"].add(image)
|
|
112
|
+
|
|
113
|
+
# add to compose
|
|
114
|
+
self.compose["services"]["home"] = {
|
|
115
|
+
"image": image.source,
|
|
116
|
+
"container_name": "home",
|
|
117
|
+
"pull_policy": "never",
|
|
118
|
+
"restart": "unless-stopped",
|
|
119
|
+
"expose": ["80"],
|
|
120
|
+
"volumes": [
|
|
121
|
+
{
|
|
122
|
+
"type": "bind",
|
|
123
|
+
"source": str(CONTENT_TARGET_PATH / "dashboard.yaml"),
|
|
124
|
+
"target": "/src/home.yaml",
|
|
125
|
+
"read_only": True,
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def gen_dashboard_config(self):
|
|
131
|
+
"""Generate and add YAML config file for dashboard, based on entries"""
|
|
132
|
+
|
|
133
|
+
if self.dashboard_offers_zim_downloads:
|
|
134
|
+
download_fqdn = f"download-zims.{self.fqdn}"
|
|
135
|
+
else:
|
|
136
|
+
download_fqdn = None
|
|
137
|
+
|
|
138
|
+
payload = {
|
|
139
|
+
"metadata": {"name": self.name, "fqdn": self.fqdn},
|
|
140
|
+
"packages": [
|
|
141
|
+
package.to_dashboard_entry(fqdn=self.fqdn, download_fqdn=download_fqdn)
|
|
142
|
+
for package in self.dashboard_entries
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
self.add_file(
|
|
147
|
+
url_or_content=BlockStr(yaml_dump(payload)),
|
|
148
|
+
to=str(CONTENT_TARGET_PATH / "dashboard.yaml"),
|
|
149
|
+
size=0,
|
|
150
|
+
via="direct",
|
|
151
|
+
is_url=False,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def add_reverseproxy(self):
|
|
155
|
+
if self.with_reverseproxy:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self.with_reverseproxy = True
|
|
159
|
+
|
|
160
|
+
image = OCIImage(
|
|
161
|
+
ident="ghcr.io/offspot/reverse-proxy:1.1",
|
|
162
|
+
filesize=114974720,
|
|
163
|
+
fullsize=114894424,
|
|
164
|
+
)
|
|
165
|
+
self.config["oci_images"].add(image)
|
|
166
|
+
|
|
167
|
+
# add to compose
|
|
168
|
+
self.compose["services"]["reverse-proxy"] = {
|
|
169
|
+
"image": image.source,
|
|
170
|
+
"container_name": "reverse-proxy",
|
|
171
|
+
"environment": {
|
|
172
|
+
"FQDN": self.fqdn,
|
|
173
|
+
},
|
|
174
|
+
"pull_policy": "never",
|
|
175
|
+
"restart": "unless-stopped",
|
|
176
|
+
"ports": ["80:80", "443:443"],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def add_captive_portal(self):
|
|
180
|
+
if self.with_captive_portal:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
self.with_captive_portal = True
|
|
184
|
+
|
|
185
|
+
image = OCIImage(
|
|
186
|
+
ident="ghcr.io/offspot/captive-portal:1.0",
|
|
187
|
+
filesize=187668480,
|
|
188
|
+
fullsize=187604243,
|
|
189
|
+
)
|
|
190
|
+
self.config["oci_images"].add(image)
|
|
191
|
+
|
|
192
|
+
# add to compose
|
|
193
|
+
self.compose["services"]["home-portal"] = {
|
|
194
|
+
"image": image.source,
|
|
195
|
+
"container_name": "home-portal",
|
|
196
|
+
"network_mode": "host",
|
|
197
|
+
"cap_add": [
|
|
198
|
+
"NET_ADMIN",
|
|
199
|
+
],
|
|
200
|
+
"environment": {
|
|
201
|
+
"HOTSPOT_NAME": self.name,
|
|
202
|
+
"HOTSPOT_IP": "192.168.2.1",
|
|
203
|
+
"HOTSPOT_FQDN": self.fqdn,
|
|
204
|
+
"CAPTURED_NETWORKS": "192.168.2.128/25",
|
|
205
|
+
"TIMEOUT": "60",
|
|
206
|
+
"FILTER_MODULE": "portal_filter",
|
|
207
|
+
},
|
|
208
|
+
"pull_policy": "never",
|
|
209
|
+
"restart": "unless-stopped",
|
|
210
|
+
"expose": ["2080", "2443"],
|
|
211
|
+
"volumes": [
|
|
212
|
+
{
|
|
213
|
+
"type": "bind",
|
|
214
|
+
"source": "/var/run/internet",
|
|
215
|
+
"target": "/var/run/internet",
|
|
216
|
+
"read_only": True,
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def add_hwclock(self):
|
|
222
|
+
if self.with_hwclock:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
self.with_hwclock = True
|
|
226
|
+
|
|
227
|
+
# add image
|
|
228
|
+
image = OCIImage(
|
|
229
|
+
ident="ghcr.io/offspot/hwclock:1.0",
|
|
230
|
+
filesize=59412480,
|
|
231
|
+
fullsize=59382985,
|
|
232
|
+
)
|
|
233
|
+
self.config["oci_images"].add(image)
|
|
234
|
+
|
|
235
|
+
# add to compose
|
|
236
|
+
self.compose["services"]["hwclock"] = {
|
|
237
|
+
"image": image.source,
|
|
238
|
+
"container_name": "hwclock",
|
|
239
|
+
"pull_policy": "never",
|
|
240
|
+
"read_only": True,
|
|
241
|
+
"restart": "unless-stopped",
|
|
242
|
+
"expose": ["80"],
|
|
243
|
+
"cap_add": ["CAP_SYS_TIME"],
|
|
244
|
+
"privileged": True,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
self.reversed_services.add("hwclock")
|
|
248
|
+
|
|
249
|
+
def add_zim(self, zim: ZimPackage):
|
|
250
|
+
if zim not in self.dashboard_entries:
|
|
251
|
+
self.dashboard_entries.append(zim)
|
|
252
|
+
|
|
253
|
+
self.add_file(
|
|
254
|
+
url_or_content=zim.download_url,
|
|
255
|
+
to=str(CONTENT_TARGET_PATH / "zims" / zim.filename),
|
|
256
|
+
via="direct",
|
|
257
|
+
size=zim.download_size,
|
|
258
|
+
is_url=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if self.with_kiwixserve:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
self.with_kiwixserve = True
|
|
265
|
+
|
|
266
|
+
image = OCIImage(
|
|
267
|
+
ident="ghcr.io/offspot/kiwix-serve:3.5.0-2",
|
|
268
|
+
filesize=29194240,
|
|
269
|
+
fullsize=29162475,
|
|
270
|
+
)
|
|
271
|
+
self.config["oci_images"].add(image)
|
|
272
|
+
|
|
273
|
+
# add to compose
|
|
274
|
+
self.compose["services"]["kiwix"] = {
|
|
275
|
+
"image": image.source,
|
|
276
|
+
"container_name": "kiwix",
|
|
277
|
+
"pull_policy": "never",
|
|
278
|
+
"restart": "unless-stopped",
|
|
279
|
+
"expose": ["80"],
|
|
280
|
+
"volumes": [
|
|
281
|
+
{
|
|
282
|
+
"type": "bind",
|
|
283
|
+
"source": f"{CONTENT_TARGET_PATH}/zims",
|
|
284
|
+
"target": "/data",
|
|
285
|
+
"read_only": True,
|
|
286
|
+
}
|
|
287
|
+
],
|
|
288
|
+
"command": '/bin/sh -c "kiwix-serve --blockexternal '
|
|
289
|
+
'--port 80 --nodatealiases /data/*.zim"',
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if self.dashboard_offers_zim_downloads:
|
|
293
|
+
self.add_files_service()
|
|
294
|
+
self.files_mapping.update({"zim-downloads": "zims"})
|
|
295
|
+
|
|
296
|
+
self.reversed_services.add("kiwix")
|
|
297
|
+
|
|
298
|
+
def add_app(self, package: AppPackage):
|
|
299
|
+
if package.kind != "app":
|
|
300
|
+
raise ValueError(f"Package {package.ident} is not an app")
|
|
301
|
+
|
|
302
|
+
if package.ident in self.compose["services"]:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# add file if app includes one
|
|
306
|
+
if package.has_file():
|
|
307
|
+
self.config["files"].append(package.as_fileconfig())
|
|
308
|
+
|
|
309
|
+
self.config["oci_images"].add(package.oci_image)
|
|
310
|
+
self.compose["services"][package.ident] = {
|
|
311
|
+
"image": package.oci_image.source,
|
|
312
|
+
"container_name": package.domain,
|
|
313
|
+
"pull_policy": "never",
|
|
314
|
+
"restart": "unless-stopped",
|
|
315
|
+
"expose": ["80"],
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
self.reversed_services.add(package.domain)
|
|
319
|
+
|
|
320
|
+
def add_files_package(self, package: FilesPackage):
|
|
321
|
+
# add package to dashboard for a link
|
|
322
|
+
if package in self.dashboard_entries:
|
|
323
|
+
return
|
|
324
|
+
self.dashboard_entries.append(package)
|
|
325
|
+
|
|
326
|
+
# add to files so it gets downloaded
|
|
327
|
+
if package.as_fileconfig() not in self.config["files"]:
|
|
328
|
+
self.config["files"].append(package.as_fileconfig())
|
|
329
|
+
|
|
330
|
+
self.add_files_service()
|
|
331
|
+
|
|
332
|
+
# add to package_entries to it gets its endpoint in reverse proxy
|
|
333
|
+
if package not in self.file_entries:
|
|
334
|
+
self.file_entries.append(package)
|
|
335
|
+
|
|
336
|
+
self.files_mapping.update({package.domain: package.ident})
|
|
337
|
+
|
|
338
|
+
self.reversed_services.add("files")
|
|
339
|
+
|
|
340
|
+
def add_files_service(self):
|
|
341
|
+
# add image to compose
|
|
342
|
+
if not self.with_files:
|
|
343
|
+
image = OCIImage(
|
|
344
|
+
ident="ghcr.io/offspot/file-browser:1.0",
|
|
345
|
+
filesize=47226880,
|
|
346
|
+
fullsize=47162907,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# add to compose
|
|
350
|
+
self.config["oci_images"].add(image)
|
|
351
|
+
self.compose["services"]["files"] = {
|
|
352
|
+
"image": image.source,
|
|
353
|
+
"container_name": "files",
|
|
354
|
+
"pull_policy": "never",
|
|
355
|
+
"restart": "unless-stopped",
|
|
356
|
+
"expose": ["80"],
|
|
357
|
+
"volumes": [
|
|
358
|
+
{
|
|
359
|
+
"type": "bind",
|
|
360
|
+
"source": f"{CONTENT_TARGET_PATH}/files",
|
|
361
|
+
"target": "/data",
|
|
362
|
+
"read_only": True,
|
|
363
|
+
}
|
|
364
|
+
],
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
self.with_files = True
|
|
368
|
+
|
|
369
|
+
def add_file(
|
|
370
|
+
self,
|
|
371
|
+
*,
|
|
372
|
+
url_or_content: str,
|
|
373
|
+
to: str,
|
|
374
|
+
via: str,
|
|
375
|
+
size: int,
|
|
376
|
+
is_url: bool | None = True,
|
|
377
|
+
):
|
|
378
|
+
self.config["files"].append(
|
|
379
|
+
FileConfig(
|
|
380
|
+
**{
|
|
381
|
+
"url" if is_url else "content": url_or_content,
|
|
382
|
+
"to": to,
|
|
383
|
+
"via": via,
|
|
384
|
+
"size": size,
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def render(self) -> str:
|
|
390
|
+
"""compute config based on requests"""
|
|
391
|
+
...
|
|
392
|
+
|
|
393
|
+
# add kiwix apps?
|
|
394
|
+
|
|
395
|
+
# gen dashboard.yaml
|
|
396
|
+
if self.with_dashboard:
|
|
397
|
+
self.gen_dashboard_config()
|
|
398
|
+
|
|
399
|
+
# update reverseproxy config
|
|
400
|
+
# > domain to subfolder for all files packages + zim-dl
|
|
401
|
+
self.compose["services"]["reverse-proxy"]["environment"].update(
|
|
402
|
+
{
|
|
403
|
+
"SERVICES": ",".join(self.reversed_services),
|
|
404
|
+
"FILES_MAPPING": ",".join(
|
|
405
|
+
f"{domain}:{folder}"
|
|
406
|
+
for domain, folder in self.files_mapping.items()
|
|
407
|
+
),
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# render compose
|
|
412
|
+
|
|
413
|
+
# compute output size
|
|
414
|
+
# self.config["output"] = get_size_for(self.config)
|
|
415
|
+
|
|
416
|
+
return yaml_dump(self.config)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from offspot_config.packages import AppPackage, FilesPackage, Package
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Catalog(dict):
|
|
10
|
+
def __init__(self, content: str | None = None):
|
|
11
|
+
super().__init__()
|
|
12
|
+
if content:
|
|
13
|
+
self.update_from(content)
|
|
14
|
+
|
|
15
|
+
def update_from(self, content: str):
|
|
16
|
+
for entry in json.loads(content):
|
|
17
|
+
cls = {"app": AppPackage, "files": FilesPackage}[entry["kind"]]
|
|
18
|
+
self[entry["ident"]] = cls(**entry)
|
|
19
|
+
|
|
20
|
+
def get_package(self, ident: str) -> Package:
|
|
21
|
+
if self[ident].kind == "app":
|
|
22
|
+
return cast(AppPackage, self[ident])
|
|
23
|
+
elif self[ident].kind == "files":
|
|
24
|
+
return cast(FilesPackage, self[ident])
|
|
25
|
+
return self[ident]
|
|
26
|
+
|
|
27
|
+
def get_apppackage(self, ident: str) -> AppPackage:
|
|
28
|
+
package = self.get_package(ident)
|
|
29
|
+
if not isinstance(package, AppPackage):
|
|
30
|
+
raise KeyError("No app matching {ident}")
|
|
31
|
+
return package
|
|
32
|
+
|
|
33
|
+
def get_filespackage(self, ident: str) -> FilesPackage:
|
|
34
|
+
package = self.get_package(ident)
|
|
35
|
+
if not isinstance(package, FilesPackage):
|
|
36
|
+
raise KeyError("No files matching {ident}")
|
|
37
|
+
return package
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import shutil
|
|
5
|
+
|
|
6
|
+
DATA_PART_PATH: pathlib.Path = pathlib.Path("/data")
|
|
7
|
+
CONTENT_TARGET_PATH: pathlib.Path = DATA_PART_PATH / "contents"
|
|
8
|
+
SUPPORTED_UNPACKING_FORMATS: list[str] = [f[0] for f in shutil.get_unpack_formats()]
|
|
9
|
+
POST_EXPANSION_UNWANTED_PATTERNS = (
|
|
10
|
+
### Linux ###
|
|
11
|
+
"*~",
|
|
12
|
+
# temp files which can be created if process still has handle open of a deleted file
|
|
13
|
+
".fuse_hidden*",
|
|
14
|
+
# KDE directory preferences
|
|
15
|
+
".directory",
|
|
16
|
+
# Linux trash folder which might appear on any partition or disk
|
|
17
|
+
".Trash-*",
|
|
18
|
+
# .nfs files are created when an open file is removed but is still being accessed
|
|
19
|
+
".nfs*",
|
|
20
|
+
### macOS ###
|
|
21
|
+
".DS_Store",
|
|
22
|
+
".AppleDouble",
|
|
23
|
+
".LSOverride",
|
|
24
|
+
# Thumbnails
|
|
25
|
+
"._*",
|
|
26
|
+
# Files that might appear in the root of a volume
|
|
27
|
+
".DocumentRevisions-V100",
|
|
28
|
+
".fseventsd",
|
|
29
|
+
".Spotlight-V100",
|
|
30
|
+
".TemporaryItems",
|
|
31
|
+
".Trashes",
|
|
32
|
+
".VolumeIcon.icns",
|
|
33
|
+
".com.apple.timemachine.donotpresent",
|
|
34
|
+
# Directories potentially created on remote AFP share
|
|
35
|
+
".AppleDB",
|
|
36
|
+
".AppleDesktop",
|
|
37
|
+
"Network Trash Folder",
|
|
38
|
+
".apdisk",
|
|
39
|
+
# iCloud generated files
|
|
40
|
+
"*.icloud",
|
|
41
|
+
### Windows ###
|
|
42
|
+
# Windows thumbnail cache files
|
|
43
|
+
"Thumbs.db",
|
|
44
|
+
"Thumbs.db:encryptable",
|
|
45
|
+
"ehthumbs.db",
|
|
46
|
+
"ehthumbs_vista.db",
|
|
47
|
+
# Dump file
|
|
48
|
+
"*.stackdump",
|
|
49
|
+
# Folder config file
|
|
50
|
+
"desktop.ini",
|
|
51
|
+
"Desktop.ini",
|
|
52
|
+
# Recycle Bin used on file shares
|
|
53
|
+
"$RECYCLE.BIN",
|
|
54
|
+
# Windows shortcuts
|
|
55
|
+
"*.lnk",
|
|
56
|
+
)
|
offspot_config/file.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import urllib.parse
|
|
5
|
+
|
|
6
|
+
from offspot_config.constants import DATA_PART_PATH, SUPPORTED_UNPACKING_FORMATS
|
|
7
|
+
from offspot_config.utils.download import get_online_rsc_size
|
|
8
|
+
from offspot_config.utils.misc import get_filesize
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class File:
|
|
12
|
+
"""In-Config reference to a file to write to the data partition
|
|
13
|
+
|
|
14
|
+
Created from files entries in config:
|
|
15
|
+
- to: str
|
|
16
|
+
mandatory destination to save file into. Must be inside /data
|
|
17
|
+
- size: optional[int]
|
|
18
|
+
size of (expanded) content. If specified, must be >= source file
|
|
19
|
+
- via: optional[str]
|
|
20
|
+
method to process source file (not for content). Values in File.unpack_formats
|
|
21
|
+
- url: optional[str]
|
|
22
|
+
URL to download file from
|
|
23
|
+
- content: optional[str]
|
|
24
|
+
plain text content to write to destination
|
|
25
|
+
|
|
26
|
+
one of content or url must be supplied. content has priority"""
|
|
27
|
+
|
|
28
|
+
kind: str = "file" # Item interface
|
|
29
|
+
|
|
30
|
+
unpack_formats: list[str]
|
|
31
|
+
|
|
32
|
+
def __init__(self, payload: dict[str, str | int]):
|
|
33
|
+
self.unpack_formats = ["direct", *SUPPORTED_UNPACKING_FORMATS]
|
|
34
|
+
self.url: urllib.parse.ParseResult | None = None
|
|
35
|
+
self.content: str = str(payload.get("content", "") or "").strip()
|
|
36
|
+
|
|
37
|
+
if not self.content:
|
|
38
|
+
try:
|
|
39
|
+
self.url = urllib.parse.urlparse(str(payload["url"]))
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
raise ValueError(f"URL “{payload.get('url')}” is incorrect") from exc
|
|
42
|
+
|
|
43
|
+
self.to: pathlib.Path = pathlib.Path(str(payload["to"])).resolve()
|
|
44
|
+
if not self.to.is_relative_to(DATA_PART_PATH):
|
|
45
|
+
raise ValueError(f"{self.to} not a descendent of {DATA_PART_PATH}")
|
|
46
|
+
|
|
47
|
+
self.via: str = str(payload.get("via", "direct"))
|
|
48
|
+
if self.via not in self.unpack_formats:
|
|
49
|
+
raise NotImplementedError(f"Unsupported handler `{self.via}`")
|
|
50
|
+
|
|
51
|
+
# initialized has unknown
|
|
52
|
+
self._size: int = int(payload.get("size", -1))
|
|
53
|
+
self._fullsize: int | None = (
|
|
54
|
+
int(payload["fullsize"]) if "fullsize" in payload else None
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def source(self) -> str: # Item interface
|
|
59
|
+
return self.geturl()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def size(self): # Item interface
|
|
63
|
+
if self._size < 0:
|
|
64
|
+
return self.fetch_size()
|
|
65
|
+
return self._size
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def fullsize(self):
|
|
69
|
+
return self._fullsize or self.size
|
|
70
|
+
|
|
71
|
+
def fetch_size(self, *, force: bool | None = False) -> int:
|
|
72
|
+
"""retrieve size of source, making sure it's reachable"""
|
|
73
|
+
if not force and self._size >= 0:
|
|
74
|
+
return self._size
|
|
75
|
+
self._size = (
|
|
76
|
+
get_filesize(self.getpath())
|
|
77
|
+
if self.is_local
|
|
78
|
+
else get_online_rsc_size(self.geturl())
|
|
79
|
+
)
|
|
80
|
+
return self._size
|
|
81
|
+
|
|
82
|
+
def geturl(self) -> str:
|
|
83
|
+
"""URL as string"""
|
|
84
|
+
return self.url.geturl() if self.url else ""
|
|
85
|
+
|
|
86
|
+
def getpath(self) -> pathlib.Path:
|
|
87
|
+
"""URL as a local path"""
|
|
88
|
+
return pathlib.Path(self.url.path if self.url else "").expanduser().resolve()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_direct(self):
|
|
92
|
+
return self.via == "direct"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_plain(self) -> bool:
|
|
96
|
+
"""whether a plain text content to be written"""
|
|
97
|
+
return bool(self.content)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_local(self) -> bool:
|
|
101
|
+
"""whether referencing a local file"""
|
|
102
|
+
return not self.is_plain and bool(self.url) and self.url.scheme == "file"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_remote(self) -> bool:
|
|
106
|
+
"""whether referencing a remote file"""
|
|
107
|
+
return not self.content and bool(self.url) and self.url.scheme != "file"
|
|
108
|
+
|
|
109
|
+
def mounted_to(self, mount_point: pathlib.Path):
|
|
110
|
+
"""destination (to) path inside mount-point"""
|
|
111
|
+
return mount_point.joinpath(self.to.relative_to(DATA_PART_PATH))
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
msg = f"File(to={self.to}, via={self.via}"
|
|
115
|
+
if self.url:
|
|
116
|
+
msg += f", url={self.geturl()}"
|
|
117
|
+
if self.content:
|
|
118
|
+
msg += f", content={self.content.splitlines()[0][:10]}"
|
|
119
|
+
msg += f", size={self.size})"
|
|
120
|
+
return msg
|
|
121
|
+
|
|
122
|
+
def __str__(self) -> str:
|
|
123
|
+
return repr(self)
|