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 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()
@@ -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}")
@@ -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
+ &nbsp;GSELib
30
+ </h1>
31
+
32
+
33
+ [![License](https://img.shields.io/badge/License-MIT-teal.svg)](LICENSE.txt) [![Python](https://img.shields.io/badge/Python-3.14-22558a.svg?logo=python&color=22558a)](https://www.python.org/)
34
+ [![Tests](https://github.com/gsecars/gselib/actions/workflows/tests.yml/badge.svg)](https://github.com/gsecars/gselib/actions/workflows/tests.yml)
35
+ [![Docs](https://github.com/gsecars/gselib/actions/workflows/docs.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gselib = gselib:main
@@ -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.