gselib 0.1.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.
- gselib/__init__.py +40 -0
- gselib/cloud/__init__.py +25 -0
- gselib/cloud/nextcloud/__init__.py +138 -0
- gselib/cloud/nextcloud/client.py +62 -0
- gselib/cloud/nextcloud/occ.py +93 -0
- gselib/deps.py +43 -0
- gselib/logbook/__init__.py +53 -0
- gselib/logbook/auth.py +22 -0
- gselib/logbook/document.py +11 -0
- gselib/logbook/logbook.py +65 -0
- gselib/logbook/styles.py +40 -0
- gselib-0.1.0.dist-info/METADATA +57 -0
- gselib-0.1.0.dist-info/RECORD +16 -0
- gselib-0.1.0.dist-info/WHEEL +4 -0
- gselib-0.1.0.dist-info/entry_points.txt +2 -0
- gselib-0.1.0.dist-info/licenses/LICENSE.txt +22 -0
gselib/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from argparse import ArgumentParser
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
|
|
8
|
+
from gselib.cloud import add_cloud_subparser, run_cloud_command
|
|
9
|
+
|
|
10
|
+
__version__ = version("gselib")
|
|
11
|
+
__all__ = ["__version__", "main"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_parser() -> ArgumentParser:
|
|
15
|
+
"""Builds the gselib argument parser."""
|
|
16
|
+
parser = ArgumentParser(f"gselib {__version__}")
|
|
17
|
+
parser.add_argument("-t", "--test", action="store_true", help="runs the test suite")
|
|
18
|
+
|
|
19
|
+
subparsers = parser.add_subparsers(dest="tool", metavar="tool")
|
|
20
|
+
add_cloud_subparser(subparsers)
|
|
21
|
+
|
|
22
|
+
return parser
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Runs the gselib CLI."""
|
|
27
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout, force=True)
|
|
28
|
+
parser = make_parser()
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
|
|
31
|
+
if args.test:
|
|
32
|
+
import pytest
|
|
33
|
+
|
|
34
|
+
raise SystemExit(pytest.main([]))
|
|
35
|
+
|
|
36
|
+
if args.tool == "cloud":
|
|
37
|
+
run_cloud_command(args)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
parser.print_help()
|
gselib/cloud/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from gselib.cloud.nextcloud import add_nextcloud_subparser, run_nextcloud_command
|
|
4
|
+
from gselib.deps import available
|
|
5
|
+
|
|
6
|
+
if available("cloud"):
|
|
7
|
+
from gselib.cloud.nextcloud import NextcloudClient, NextcloudOCC
|
|
8
|
+
|
|
9
|
+
__all__ = ["NextcloudClient", "NextcloudOCC"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_cloud_subparser(subparsers) -> None:
|
|
13
|
+
"""Register the cloud subcommand on the given subparsers."""
|
|
14
|
+
if not available("cloud"):
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
cloud = subparsers.add_parser("cloud", help="cloud storage and share management")
|
|
18
|
+
services = cloud.add_subparsers(dest="cloud_service", metavar="service", required=True)
|
|
19
|
+
add_nextcloud_subparser(services)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_cloud_command(args) -> None:
|
|
23
|
+
"""Dispatch a parsed cloud command to the appropriate service handler."""
|
|
24
|
+
if args.cloud_service == "nextcloud":
|
|
25
|
+
run_nextcloud_command(args)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from gselib.deps import available, require
|
|
9
|
+
|
|
10
|
+
__all__ = ["NextcloudClient", "NextcloudOCC"]
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_IS_AVAILABLE = available("cloud")
|
|
15
|
+
|
|
16
|
+
if _IS_AVAILABLE:
|
|
17
|
+
from gselib.cloud.nextcloud.client import NextcloudClient
|
|
18
|
+
from gselib.cloud.nextcloud.occ import NextcloudOCC
|
|
19
|
+
|
|
20
|
+
_PERMISSIONS = {
|
|
21
|
+
"read": NextcloudClient.perm_read,
|
|
22
|
+
"update": NextcloudClient.perm_update,
|
|
23
|
+
"create": NextcloudClient.perm_create,
|
|
24
|
+
"delete": NextcloudClient.perm_delete,
|
|
25
|
+
"share": NextcloudClient.perm_share,
|
|
26
|
+
"all": NextcloudClient.perm_all,
|
|
27
|
+
}
|
|
28
|
+
else:
|
|
29
|
+
_PERMISSIONS = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_nextcloud_subparser(subparsers) -> None:
|
|
33
|
+
"""Registers the nextcloud subcommand on the subparsers."""
|
|
34
|
+
if not _IS_AVAILABLE:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
nextcloud = subparsers.add_parser("nextcloud", help="Nextcloud storage and share management")
|
|
38
|
+
|
|
39
|
+
occ = nextcloud.add_argument_group("OCC - storage commands (env: NEXTCLOUD_OCC_CMD)")
|
|
40
|
+
occ.add_argument("--occ-cmd", metavar="CMD")
|
|
41
|
+
|
|
42
|
+
ssh = nextcloud.add_argument_group("SSH - connect to a remote host (env: NEXTCLOUD_SSH_HOST / SSH_USER / SSH_KEY)")
|
|
43
|
+
ssh.add_argument("--ssh-host", metavar="HOST")
|
|
44
|
+
ssh.add_argument("--ssh-user", metavar="USER")
|
|
45
|
+
ssh.add_argument("--ssh-key", metavar="PATH", help="SSH private key (skip to use a password prompt)")
|
|
46
|
+
|
|
47
|
+
api = nextcloud.add_argument_group("REST API - share commands (env: NEXTCLOUD_URL / USER / PASSWORD)")
|
|
48
|
+
api.add_argument("--url", metavar="URL")
|
|
49
|
+
api.add_argument("--user", metavar="USER")
|
|
50
|
+
api.add_argument("--password", metavar="PASS", help="skip to use a password prompt")
|
|
51
|
+
|
|
52
|
+
commands = nextcloud.add_subparsers(dest="nextcloud_command", metavar="command", required=True)
|
|
53
|
+
|
|
54
|
+
p = commands.add_parser("create-storage", help="create a local external storage mount")
|
|
55
|
+
p.add_argument("--mount", required=True, metavar="NAME")
|
|
56
|
+
p.add_argument("--path", required=True, metavar="PATH")
|
|
57
|
+
p.add_argument("--users", nargs="+", metavar="USER")
|
|
58
|
+
p.add_argument("--groups", nargs="+", metavar="GROUP")
|
|
59
|
+
|
|
60
|
+
commands.add_parser("list-storages", help="list external storage mounts")
|
|
61
|
+
|
|
62
|
+
p = commands.add_parser("delete-storage", help="delete an external storage mount")
|
|
63
|
+
p.add_argument("--id", required=True, type=int, metavar="ID")
|
|
64
|
+
|
|
65
|
+
p = commands.add_parser("share", help="share a path with one or more users")
|
|
66
|
+
p.add_argument("--nc-path", required=True, metavar="PATH")
|
|
67
|
+
p.add_argument("--recipients", required=True, nargs="+", metavar="RECIPIENT")
|
|
68
|
+
p.add_argument("--permissions", nargs="+", metavar="PERM", default=["read"], choices=list(_PERMISSIONS))
|
|
69
|
+
|
|
70
|
+
p = commands.add_parser("list-shares", help="list shares")
|
|
71
|
+
p.add_argument("--nc-path", metavar="PATH")
|
|
72
|
+
|
|
73
|
+
p = commands.add_parser("delete-share", help="remove a share")
|
|
74
|
+
p.add_argument("--id", required=True, type=int, metavar="ID")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@require("cloud")
|
|
78
|
+
def run_nextcloud_command(args) -> None:
|
|
79
|
+
"""Runs the appropriate handler for the nextcloud command."""
|
|
80
|
+
cmd = args.nextcloud_command
|
|
81
|
+
|
|
82
|
+
if cmd in ("create-storage", "list-storages", "delete-storage"):
|
|
83
|
+
with _occ_from_args(args) as occ:
|
|
84
|
+
if cmd == "create-storage":
|
|
85
|
+
mount = occ.create_local_storage(args.mount, args.path, args.users or None, args.groups or None)
|
|
86
|
+
logger.info("Created storage: id=%s mount=%s", mount.get("id"), mount.get("mount_point"))
|
|
87
|
+
elif cmd == "list-storages":
|
|
88
|
+
mounts = occ.list_storages()
|
|
89
|
+
for m in mounts:
|
|
90
|
+
logger.info(" id=%-4s %s", m.get("mount_id") or m.get("id", "?"), m.get("mount_point") or m.get("mountPoint", "?"))
|
|
91
|
+
elif cmd == "delete-storage":
|
|
92
|
+
occ.delete_storage(args.id)
|
|
93
|
+
logger.info("Deleted storage id=%s.", args.id)
|
|
94
|
+
else:
|
|
95
|
+
client = _client_from_args(args)
|
|
96
|
+
|
|
97
|
+
if cmd == "share":
|
|
98
|
+
bits = sum(_PERMISSIONS[p] for p in args.permissions)
|
|
99
|
+
shares = client.share_with_many(args.nc_path, args.recipients, bits)
|
|
100
|
+
for s in shares:
|
|
101
|
+
logger.info(" Shared %r with %r (id=%s)", args.nc_path, s.get("share_with") or "?", s.get("id"))
|
|
102
|
+
logger.info("Created %d share(s).", len(shares))
|
|
103
|
+
elif cmd == "list-shares":
|
|
104
|
+
shares = client.list_shares(path=args.nc_path)
|
|
105
|
+
for s in shares:
|
|
106
|
+
logger.info(" id=%-4s %-22s %s", s.get("id", "?"), s.get("path", "?"), s.get("share_with") or "?")
|
|
107
|
+
elif cmd == "delete-share":
|
|
108
|
+
client.delete_share(args.id)
|
|
109
|
+
logger.info("Deleted share id=%s.", args.id)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _occ_from_args(args) -> "NextcloudOCC":
|
|
113
|
+
"""Builds a NextcloudOCC instance from CLI args and environment variables."""
|
|
114
|
+
host = args.ssh_host or os.environ.get("NEXTCLOUD_SSH_HOST")
|
|
115
|
+
occ_cmd = args.occ_cmd or os.environ.get("NEXTCLOUD_OCC_CMD")
|
|
116
|
+
occ_kwargs = {"occ_cmd": occ_cmd} if occ_cmd else {}
|
|
117
|
+
if not host:
|
|
118
|
+
return NextcloudOCC(**occ_kwargs)
|
|
119
|
+
user = args.ssh_user or os.environ.get("NEXTCLOUD_SSH_USER")
|
|
120
|
+
if not user:
|
|
121
|
+
sys.exit("error: --ssh-user is required when --ssh-host is set (or set NEXTCLOUD_SSH_USER)")
|
|
122
|
+
key = args.ssh_key or os.environ.get("NEXTCLOUD_SSH_KEY")
|
|
123
|
+
password = None if key else getpass.getpass(f"SSH password for {user}@{host}: ")
|
|
124
|
+
return NextcloudOCC(host=host, user=user, key_path=key, password=password, **occ_kwargs)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _client_from_args(args) -> "NextcloudClient":
|
|
128
|
+
"""Builds a NextcloudClient instance from CLI args and environment variables."""
|
|
129
|
+
url = args.url or os.environ.get("NEXTCLOUD_URL")
|
|
130
|
+
user = args.user or os.environ.get("NEXTCLOUD_USER")
|
|
131
|
+
password = args.password or os.environ.get("NEXTCLOUD_PASSWORD")
|
|
132
|
+
if not url:
|
|
133
|
+
sys.exit("error: --url is required (or set NEXTCLOUD_URL)")
|
|
134
|
+
if not user:
|
|
135
|
+
sys.exit("error: --user is required (or set NEXTCLOUD_USER)")
|
|
136
|
+
if not password:
|
|
137
|
+
password = getpass.getpass(f"Nextcloud password for {user}: ")
|
|
138
|
+
return NextcloudClient(url, user, password)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from requests.auth import HTTPBasicAuth
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class NextcloudClient:
|
|
11
|
+
share_type_user = 0
|
|
12
|
+
share_type_federated = 6
|
|
13
|
+
|
|
14
|
+
perm_read = 1
|
|
15
|
+
perm_update = 2
|
|
16
|
+
perm_create = 4
|
|
17
|
+
perm_delete = 8
|
|
18
|
+
perm_share = 16
|
|
19
|
+
perm_all = 31
|
|
20
|
+
|
|
21
|
+
base_url: str
|
|
22
|
+
user: str
|
|
23
|
+
password: str
|
|
24
|
+
_auth: HTTPBasicAuth = field(init=False, repr=False, compare=False)
|
|
25
|
+
_headers: dict = field(init=False, repr=False, compare=False)
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
self.base_url = self.base_url.rstrip("/")
|
|
29
|
+
self._auth = HTTPBasicAuth(self.user, self.password)
|
|
30
|
+
self._headers = {"OCS-APIREQUEST": "true", "Accept": "application/json"}
|
|
31
|
+
|
|
32
|
+
def _request(self, method: str, path: str, data: dict = None) -> dict | list:
|
|
33
|
+
"""Executes an OCS API request and returns the data payload."""
|
|
34
|
+
response = requests.request(method, f"{self.base_url}{path}", data=data, auth=self._auth, headers=self._headers, timeout=30)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
body = response.json()
|
|
37
|
+
meta = body.get("ocs", {}).get("meta", {})
|
|
38
|
+
if meta.get("statuscode") not in (100, 200):
|
|
39
|
+
raise RuntimeError(f"OCS {meta.get('statuscode')}: {meta.get('message')}")
|
|
40
|
+
return body["ocs"]["data"]
|
|
41
|
+
|
|
42
|
+
def share(self, path: str, recipient: str, permissions: int = perm_read) -> dict:
|
|
43
|
+
"""Shares a Nextcloud path with a single user or federated address."""
|
|
44
|
+
share_type = self.share_type_federated if "@" in recipient else self.share_type_user
|
|
45
|
+
return self._request(
|
|
46
|
+
"POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", {"path": path, "shareType": share_type, "shareWith": recipient, "permissions": permissions}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def share_with_many(self, path: str, recipients: str | list[str], permissions: int = perm_read) -> list[dict]:
|
|
50
|
+
"""Shares a Nextcloud path with one or more users or federated addresses."""
|
|
51
|
+
if isinstance(recipients, str):
|
|
52
|
+
recipients = [recipients]
|
|
53
|
+
return [self.share(path, r, permissions) for r in recipients]
|
|
54
|
+
|
|
55
|
+
def list_shares(self, path: str = None) -> list:
|
|
56
|
+
"""Returns all shares, optionally filtered by path."""
|
|
57
|
+
params = f"?path={path}" if path else ""
|
|
58
|
+
return self._request("GET", f"/ocs/v2.php/apps/files_sharing/api/v1/shares{params}")
|
|
59
|
+
|
|
60
|
+
def delete_share(self, share_id: int) -> None:
|
|
61
|
+
"""Deletes a share by its ID."""
|
|
62
|
+
self._request("DELETE", f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NextcloudOCC:
|
|
11
|
+
def __init__(self, host: str = None, user: str = None, key_path: str = None, password: str = None, port: int = 22, occ_cmd: str = None) -> None:
|
|
12
|
+
"""Initializes OCC runner. Skip the host to run commands locally via subprocess."""
|
|
13
|
+
self._host = host
|
|
14
|
+
self._user = user
|
|
15
|
+
self._key_path = key_path
|
|
16
|
+
self._password = password
|
|
17
|
+
self._port = port
|
|
18
|
+
self._occ_cmd = occ_cmd
|
|
19
|
+
self._ssh = None
|
|
20
|
+
|
|
21
|
+
def _connect(self):
|
|
22
|
+
"""Opens and caches the SSH connection."""
|
|
23
|
+
if self._ssh is None:
|
|
24
|
+
import paramiko
|
|
25
|
+
|
|
26
|
+
client = paramiko.SSHClient()
|
|
27
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
28
|
+
kwargs: dict = {"username": self._user, "port": self._port}
|
|
29
|
+
if self._key_path:
|
|
30
|
+
kwargs["key_filename"] = os.path.expanduser(self._key_path)
|
|
31
|
+
kwargs["look_for_keys"] = False
|
|
32
|
+
elif self._password:
|
|
33
|
+
kwargs["password"] = self._password
|
|
34
|
+
kwargs["look_for_keys"] = False
|
|
35
|
+
kwargs["allow_agent"] = False
|
|
36
|
+
client.connect(self._host, **kwargs)
|
|
37
|
+
self._ssh = client
|
|
38
|
+
return self._ssh
|
|
39
|
+
|
|
40
|
+
def _run(self, occ_args: str) -> str:
|
|
41
|
+
"""Runs an occ command locally or over SSH and returns stdout."""
|
|
42
|
+
if self._occ_cmd is None:
|
|
43
|
+
raise ValueError("occ_cmd is required. Pass it to NextcloudOCC() or set NEXTCLOUD_OCC_CMD.")
|
|
44
|
+
cmd = f"{self._occ_cmd} --no-interaction {occ_args}"
|
|
45
|
+
if self._host:
|
|
46
|
+
ssh = self._connect()
|
|
47
|
+
_, stdout, stderr = ssh.exec_command(cmd)
|
|
48
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
49
|
+
out = stdout.read().decode().strip()
|
|
50
|
+
err = stderr.read().decode().strip()
|
|
51
|
+
else:
|
|
52
|
+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
53
|
+
exit_code = result.returncode
|
|
54
|
+
out = result.stdout.strip()
|
|
55
|
+
err = result.stderr.strip()
|
|
56
|
+
if exit_code != 0:
|
|
57
|
+
raise RuntimeError(f"occ failed (exit {exit_code}): {err or out}")
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
def create_local_storage(self, mount_point: str, server_path: str, applicable_users: list[str] = None, applicable_groups: list[str] = None) -> dict:
|
|
61
|
+
"""Creates a local-path external storage mount."""
|
|
62
|
+
args = ["files_external:create", "--output=json", shlex.quote(mount_point), "local", "null::null", f"--config=datadir={server_path}"]
|
|
63
|
+
if applicable_users:
|
|
64
|
+
args += [f"--applicable-to-user={shlex.quote(u)}" for u in applicable_users]
|
|
65
|
+
if applicable_groups:
|
|
66
|
+
args += [f"--applicable-to-group={shlex.quote(g)}" for g in applicable_groups]
|
|
67
|
+
out = self._run(" ".join(args))
|
|
68
|
+
try:
|
|
69
|
+
result = json.loads(out)
|
|
70
|
+
return {"id": result, "mount_point": mount_point} if isinstance(result, int) else result
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
match = re.search(r"\b(\d+)\b", out)
|
|
73
|
+
return {"id": int(match.group(1)) if match else None, "mount_point": mount_point}
|
|
74
|
+
|
|
75
|
+
def list_storages(self) -> list:
|
|
76
|
+
"""Returns all configured external storage mounts."""
|
|
77
|
+
return json.loads(self._run("files_external:list --output=json"))
|
|
78
|
+
|
|
79
|
+
def delete_storage(self, mount_id: int) -> None:
|
|
80
|
+
"""Deletes an external storage mount by its ID."""
|
|
81
|
+
self._run(f"files_external:delete {mount_id}")
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
"""Closes the SSH connection if open."""
|
|
85
|
+
if self._ssh:
|
|
86
|
+
self._ssh.close()
|
|
87
|
+
self._ssh = None
|
|
88
|
+
|
|
89
|
+
def __enter__(self):
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
93
|
+
self.close()
|
gselib/deps.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import re
|
|
5
|
+
from importlib.metadata import requires
|
|
6
|
+
from importlib.util import find_spec
|
|
7
|
+
|
|
8
|
+
__all__ = ["available", "require"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_groups() -> dict[str, tuple[str, ...]]:
|
|
12
|
+
"""Builds a map of extras group to package names from installed package metadata."""
|
|
13
|
+
groups: dict[str, list[str]] = {}
|
|
14
|
+
for req in requires("gselib") or []:
|
|
15
|
+
match = re.search(r'extra == ["\'](\w+)["\']', req)
|
|
16
|
+
if match:
|
|
17
|
+
pkg = re.match(r"[A-Za-z0-9_.-]+", req).group(0)
|
|
18
|
+
groups.setdefault(match.group(1), []).append(pkg)
|
|
19
|
+
return {k: tuple(v) for k, v in groups.items()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_GROUPS = _load_groups()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def available(group: str) -> bool:
|
|
26
|
+
"""Returns True if all packages for the given dependency group are installed."""
|
|
27
|
+
return all(find_spec(pkg) is not None for pkg in _GROUPS.get(group, ()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require(group: str):
|
|
31
|
+
"""Decorator that raises SystemExit if any package in the dependency group is missing."""
|
|
32
|
+
|
|
33
|
+
def decorator(func):
|
|
34
|
+
@functools.wraps(func)
|
|
35
|
+
def wrapper(*args, **kwargs):
|
|
36
|
+
missing = [pkg for pkg in _GROUPS.get(group, ()) if find_spec(pkg) is None]
|
|
37
|
+
if missing:
|
|
38
|
+
raise SystemExit(f"Missing packages for '{group}': {', '.join(missing)}. Run: pip install 'gselib[{group}]'")
|
|
39
|
+
return func(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+
return wrapper
|
|
42
|
+
|
|
43
|
+
return decorator
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser
|
|
4
|
+
|
|
5
|
+
__all__ = ["BeamlineLogbook"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_logbook_subparser(parser: ArgumentParser) -> None:
|
|
9
|
+
"""Creates a subparser for the logbook command."""
|
|
10
|
+
subparser = parser.add_subparsers(dest="logbook_command", required=True, title="commands", metavar="")
|
|
11
|
+
|
|
12
|
+
logbook = subparser.add_parser("logbook", help="beamline logbook operations via Google Docs")
|
|
13
|
+
logbook.add_argument("--doc-id", help="Google Doc ID")
|
|
14
|
+
logbook.add_argument("--env", default=".env", help="path to .env file (default: .env)")
|
|
15
|
+
|
|
16
|
+
commands = logbook.add_subparsers(dest="logbook_command", required=True)
|
|
17
|
+
|
|
18
|
+
# create
|
|
19
|
+
create = commands.add_parser("create", help="create a new logbook document")
|
|
20
|
+
create.add_argument("title", help="title for the new Google Doc")
|
|
21
|
+
|
|
22
|
+
# append
|
|
23
|
+
append = commands.add_parser("append", help="append a timestamped entry")
|
|
24
|
+
append.add_argument("text", help="message to append")
|
|
25
|
+
|
|
26
|
+
# save
|
|
27
|
+
save = commands.add_parser("save", help="export the logbook to a local file")
|
|
28
|
+
save.add_argument("path", help="output path (.pdf, .docx, .txt, or .md)")
|
|
29
|
+
|
|
30
|
+
# share
|
|
31
|
+
share = commands.add_parser("share", help="share the logbook with a user")
|
|
32
|
+
share.add_argument("email", help="recipient email address")
|
|
33
|
+
share.add_argument("--role", choices=["editor", "viewer", "commenter"], default="editor", help="permission role (default: editor)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_logbook_command(args) -> None:
|
|
37
|
+
from gselib.logbook.logbook import BeamlineLogbook
|
|
38
|
+
|
|
39
|
+
logbook = BeamlineLogbook(
|
|
40
|
+
document_id=args.doc_id if args.logbook_command != "create" else None,
|
|
41
|
+
env_file=args.env,
|
|
42
|
+
create_title=args.title if args.logbook_command == "create" else None,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if args.logbook_command == "create":
|
|
46
|
+
print(f"Created logbook '{args.title}' with document ID: {logbook.document_id}")
|
|
47
|
+
elif args.logbook_command == "append":
|
|
48
|
+
logbook.append(args.text)
|
|
49
|
+
elif args.logbook_command == "save":
|
|
50
|
+
logbook.save(args.path)
|
|
51
|
+
elif args.logbook_command == "share":
|
|
52
|
+
role_map = {"editor": "writer", "viewer": "reader", "commenter": "commenter"}
|
|
53
|
+
logbook.share(args.email, role_map[args.role])
|
gselib/logbook/auth.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SCOPES = [
|
|
7
|
+
"https://www.googleapis.com/auth/documents",
|
|
8
|
+
"https://www.googleapis.com/auth/drive",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_credentials(env_file: str = ".env") -> Credentials:
|
|
13
|
+
load_dotenv(Path(env_file))
|
|
14
|
+
service_account_file = os.environ.get("GOOGLE_SERVICE_ACCOUNT", "service-account.json")
|
|
15
|
+
return Credentials.from_service_account_file(service_account_file, scopes=SCOPES)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_services(env_file: str = ".env") -> tuple:
|
|
19
|
+
creds = load_credentials(env_file)
|
|
20
|
+
docs = build("docs", "v1", credentials=creds)
|
|
21
|
+
drive = build("drive", "v3", credentials=creds)
|
|
22
|
+
return docs, drive
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def create_document(docs, title: str) -> str:
|
|
5
|
+
doc = docs.documents().create(body={"title": title}).execute()
|
|
6
|
+
return doc["documentId"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def share_document(drive, document_id: str, email: str, role: str = "writer") -> None:
|
|
10
|
+
permission = {"type": "user", "role": role, "emailAddress": email}
|
|
11
|
+
drive.permissions().create(fileId=document_id, body=permission, sendNotificationEmail=True).execute()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .auth import get_services
|
|
8
|
+
from .document import create_document, share_document
|
|
9
|
+
from .styles import apply_logbook_template, entry_style, timestamp_style
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Logbook:
|
|
13
|
+
def __init__(self, document_id: str = None, env_file: str = ".env", create_title: str = None) -> None:
|
|
14
|
+
self._docs, self._drive = get_services(env_file)
|
|
15
|
+
|
|
16
|
+
if document_id:
|
|
17
|
+
self.document_id = document_id
|
|
18
|
+
elif create_title:
|
|
19
|
+
self.document_id = create_document(self._docs, create_title)
|
|
20
|
+
apply_logbook_template(self._docs, self.document_id, create_title)
|
|
21
|
+
else:
|
|
22
|
+
self.document_id = os.environ.get("LOGBOOK_DOCUMENT_ID")
|
|
23
|
+
if not self.document_id:
|
|
24
|
+
raise ValueError("Provide document_id, create_title, or set LOGBOOK_DOCUMENT_ID in your .env")
|
|
25
|
+
|
|
26
|
+
def append(self, text: str) -> None:
|
|
27
|
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
28
|
+
entry = f"\n[{timestamp}] {text}\n"
|
|
29
|
+
|
|
30
|
+
doc = self._docs.documents().get(documentId=self.document_id).execute()
|
|
31
|
+
end_index = doc["body"]["content"][-1]["endIndex"] - 1
|
|
32
|
+
|
|
33
|
+
ts_end = end_index + len(f"[{timestamp}]") + 2
|
|
34
|
+
|
|
35
|
+
requests = [
|
|
36
|
+
{"insertText": {"location": {"index": end_index}, "text": entry}},
|
|
37
|
+
timestamp_style(end_index + 1, ts_end),
|
|
38
|
+
entry_style(ts_end, end_index + len(entry)),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
self._docs.documents().batchUpdate(documentId=self.document_id, body={"requests": requests}).execute()
|
|
42
|
+
print(f"Appended: [{timestamp}] {text}")
|
|
43
|
+
|
|
44
|
+
def save(self, path: str) -> None:
|
|
45
|
+
ext = Path(path).suffix.lower()
|
|
46
|
+
mime_map = {
|
|
47
|
+
".pdf": "application/pdf",
|
|
48
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
49
|
+
".txt": "text/plain",
|
|
50
|
+
".md": "text/plain",
|
|
51
|
+
}
|
|
52
|
+
mime = mime_map.get(ext, "application/pdf")
|
|
53
|
+
|
|
54
|
+
content = self._drive.files().export_media(fileId=self.document_id, mimeType=mime).execute()
|
|
55
|
+
|
|
56
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
57
|
+
|
|
58
|
+
with open(path, "wb") as f:
|
|
59
|
+
f.write(content)
|
|
60
|
+
|
|
61
|
+
print(f"Saved local copy ({ext or '.pdf'}) to: {path}")
|
|
62
|
+
|
|
63
|
+
def share(self, email: str, role: str = "writer") -> None:
|
|
64
|
+
share_document(self._drive, self.document_id, email, role)
|
|
65
|
+
print(f"Shared document with {email} as {role}")
|
gselib/logbook/styles.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def heading_style(start_index: int, end_index: int) -> dict:
|
|
5
|
+
return {
|
|
6
|
+
"updateParagraphStyle": {
|
|
7
|
+
"range": {"startIndex": start_index, "endIndex": end_index},
|
|
8
|
+
"paragraphStyle": {"namedStyleType": "HEADING_1"},
|
|
9
|
+
"fields": "namedStyleType",
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def timestamp_style(start_index: int, end_index: int) -> dict:
|
|
15
|
+
return {
|
|
16
|
+
"updateTextStyle": {
|
|
17
|
+
"range": {"startIndex": start_index, "endIndex": end_index},
|
|
18
|
+
"textStyle": {"bold": True, "foregroundColor": {"color": {"rgbColor": {"red": 0.2, "green": 0.2, "blue": 0.2}}}},
|
|
19
|
+
"fields": "bold,foregroundColor",
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def entry_style(start_index: int, end_index: int) -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"updateParagraphStyle": {
|
|
27
|
+
"range": {"startIndex": start_index, "endIndex": end_index},
|
|
28
|
+
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
|
29
|
+
"fields": "namedStyleType",
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def apply_logbook_template(docs, document_id: str, title: str) -> None:
|
|
35
|
+
"""Apply heading style to the document title on a freshly created logbook."""
|
|
36
|
+
requests = [
|
|
37
|
+
{"insertText": {"location": {"index": 1}, "text": f"{title}\n"}},
|
|
38
|
+
heading_style(1, len(title) + 1),
|
|
39
|
+
]
|
|
40
|
+
docs.documents().batchUpdate(documentId=document_id, body={"requests": requests}).execute()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gselib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A collection of tools for GSECARS.
|
|
5
|
+
Project-URL: Homepage, https://github.com/gsecars/gselib
|
|
6
|
+
Project-URL: Issues, https://github.com/gsecars/gselib/issues
|
|
7
|
+
Author-email: Christofanis Skordas <skordasc@uchicago.edu>
|
|
8
|
+
Maintainer-email: Christofanis Skordas <skordasc@uchicago.edu>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE.txt
|
|
11
|
+
Classifier: Intended Audience :: Education
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: ==3.14.*
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: paramiko>=3.0; extra == 'all'
|
|
22
|
+
Requires-Dist: requests>=2.32; extra == 'all'
|
|
23
|
+
Provides-Extra: cloud
|
|
24
|
+
Requires-Dist: paramiko>=3.0; extra == 'cloud'
|
|
25
|
+
Requires-Dist: requests>=2.32; extra == 'cloud'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<h1 align="center">
|
|
29
|
+
GSELib
|
|
30
|
+
</h1>
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
[](LICENSE.txt) [](https://www.python.org/)
|
|
34
|
+
[](https://github.com/gsecars/gselib/actions/workflows/tests.yml)
|
|
35
|
+
[](https://github.com/gsecars/gselib/actions/workflows/docs.yml)
|
|
36
|
+
|
|
37
|
+
A collection of tools for GSECARS.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install gselib # base (no extras)
|
|
43
|
+
pip install 'gselib[cloud]' # with Nextcloud support
|
|
44
|
+
pip install 'gselib[all]' # everything
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Modules
|
|
48
|
+
|
|
49
|
+
| Module | Extras | Documentation |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `gselib.cloud` | `gselib[cloud]` | [Cloud](https://gsecars.github.io/gselib/cloud/) |
|
|
52
|
+
|
|
53
|
+
## Contribution
|
|
54
|
+
Read more [here](CONTRIBUTING.md) for contribution guidance.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
GSELib is distributed under the MIT License. You should have received a [copy](LICENSE.txt) of the MIT License along with this program. If not, see https://mit-license.org/ for additional details.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
gselib/__init__.py,sha256=fAy2UTVto97QQz575DP2F2u1Bbh9bkvwlsqhgudbPVQ,1009
|
|
2
|
+
gselib/deps.py,sha256=gps59Xv_C_hZeuPxpkeZDkV8RnuDwK8C4foc4h_cy_A,1412
|
|
3
|
+
gselib/cloud/__init__.py,sha256=U7Hc-MJTsUisU60DRwXirBf8a67nqwCqCSG9JohC2zs,858
|
|
4
|
+
gselib/cloud/nextcloud/__init__.py,sha256=8vy6R37Mj2Kp6LbVekHlaCLNEhnkfhTsr0OPP6CSw5k,6137
|
|
5
|
+
gselib/cloud/nextcloud/client.py,sha256=XLgZNKiR_uwizBL0CUIC52yBQ5t3OMDQfRVHa4nF75M,2588
|
|
6
|
+
gselib/cloud/nextcloud/occ.py,sha256=EK5PaICprHo9utdEbMu78sdrB-PBxHAJA02hoVsVG3M,3838
|
|
7
|
+
gselib/logbook/__init__.py,sha256=1ZAiEmLLHRUMvL9iIL8f-cymA5kS4i3j_KQ3xD__7ts,2212
|
|
8
|
+
gselib/logbook/auth.py,sha256=9Z7v38ymHZcGUybr-8OEjCuxLecrEKRsnFk7a0gCFZA,665
|
|
9
|
+
gselib/logbook/document.py,sha256=tDdk1yAeINVLvgBvYRfPV5qBrpg8N-fy5zfvhedyOt0,441
|
|
10
|
+
gselib/logbook/logbook.py,sha256=QfMbD4o12VZsAvMf-qMKy8QUiRlYe16Jm2uLQTnGB6I,2506
|
|
11
|
+
gselib/logbook/styles.py,sha256=Vlg0HWcMr8RCgErZo97Q5jTVYZphYZa1BekNGKNAh2Q,1394
|
|
12
|
+
gselib-0.1.0.dist-info/METADATA,sha256=ctTafiz7NGMhDxB_D0oR2T3ELVtFgXHzr-uyeN_Kyjo,2221
|
|
13
|
+
gselib-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
gselib-0.1.0.dist-info/entry_points.txt,sha256=9K5apVU4jU4WMIJP-VPMPMj1TnIl8uRGPd8rENKYyxs,39
|
|
15
|
+
gselib-0.1.0.dist-info/licenses/LICENSE.txt,sha256=xcp2R4dxlTVVNUaIBl71iddZUChkFD-3OUdP7BSW_cg,1142
|
|
16
|
+
gselib-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 GSECARS, University of Chicago, USA
|
|
4
|
+
Copyright (c) 2024-2026 Christofanis Skordas
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|