gslides-automator 0.4.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.
@@ -0,0 +1,43 @@
1
+ """
2
+ Package exports for gslides_automator.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import List
8
+
9
+ from gslides_automator.auth import load_credentials
10
+ from gslides_automator.generate_data import generate_data as _gd
11
+ from gslides_automator.generate_report import generate_report as _gr
12
+ from gslides_automator.drive_layout import DriveLayout, resolve_layout
13
+
14
+
15
+ def generate_data(
16
+ shared_drive_url: str,
17
+ service_account_credentials: str,
18
+ ) -> dict:
19
+ """Populate L1-Data from L0-Data using the shared-drive layout."""
20
+ creds = load_credentials(service_account_credentials)
21
+ layout: DriveLayout = resolve_layout(shared_drive_url, creds)
22
+
23
+ return _gd.generate_data(
24
+ creds=creds,
25
+ layout=layout,
26
+ )
27
+
28
+
29
+ def generate_report(
30
+ shared_drive_url: str,
31
+ service_account_credentials: str,
32
+ ) -> dict:
33
+ """Build Google Slides reports from L1-Data for entities marked with generate=Y."""
34
+ creds = load_credentials(service_account_credentials)
35
+ layout: DriveLayout = resolve_layout(shared_drive_url, creds)
36
+
37
+ return _gr.generate_report(
38
+ creds=creds,
39
+ input_folder_id=layout.l1_data_id,
40
+ template_id=layout.report_template_id,
41
+ output_folder_id=layout.l2_report_id,
42
+ layout=layout,
43
+ )
@@ -0,0 +1,7 @@
1
+ """Module entrypoint for `python -m gslides_automator`."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__": # pragma: no cover
7
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared authentication module for Google API access.
4
+ Provides service account credential management for Google Sheets, Drive, and Slides APIs.
5
+ """
6
+
7
+ from google.oauth2 import service_account
8
+ import os
9
+
10
+ # Combined scopes required by all scripts
11
+ SCOPES = [
12
+ 'https://www.googleapis.com/auth/spreadsheets', # Full access (covers readonly)
13
+ 'https://www.googleapis.com/auth/drive.readonly', # For reading/copying templates
14
+ 'https://www.googleapis.com/auth/drive.file', # For creating and moving files
15
+ 'https://www.googleapis.com/auth/drive', # Full Drive access for modifying file permissions
16
+ 'https://www.googleapis.com/auth/presentations' # For modifying slides
17
+ ]
18
+
19
+ # Get the project root directory
20
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
+ SERVICE_ACCOUNT_CREDENTIALS = os.path.join(PROJECT_ROOT, 'service-account-credentials.json')
22
+
23
+
24
+ def get_oauth_credentials(service_account_credentials: str | None = None, scopes=None):
25
+ """
26
+ Get service account credentials using a JSON key file. Defaults to service-account-credentials.json in repo root.
27
+
28
+ Args:
29
+ service_account_credentials: Optional path to a service account key JSON.
30
+ scopes: Optional list of scopes to request.
31
+
32
+ Returns:
33
+ google.oauth2.service_account.Credentials: Service account credentials object
34
+
35
+ Raises:
36
+ FileNotFoundError: If the credentials file is not found
37
+ """
38
+ key_path = service_account_credentials or SERVICE_ACCOUNT_CREDENTIALS
39
+ if not os.path.exists(key_path):
40
+ raise FileNotFoundError(
41
+ f"Service account credentials file '{key_path}' not found. "
42
+ "Please download service account credentials from Google Cloud Console."
43
+ )
44
+
45
+ requested_scopes = scopes or SCOPES
46
+
47
+ creds = service_account.Credentials.from_service_account_file(
48
+ key_path,
49
+ scopes=requested_scopes
50
+ )
51
+
52
+ return creds
53
+
54
+
55
+ def load_credentials(service_account_credentials: str):
56
+ """
57
+ Load service-account credentials from a provided path.
58
+
59
+ Args:
60
+ service_account_credentials: Path to the service account JSON key file.
61
+
62
+ Returns:
63
+ google.oauth2.service_account.Credentials: Service account credentials object
64
+
65
+ Raises:
66
+ ValueError: If service_account_credentials is empty
67
+ FileNotFoundError: If the credentials file is not found
68
+ """
69
+ if not service_account_credentials:
70
+ raise ValueError("service_account_credentials is required.")
71
+ if not os.path.exists(service_account_credentials):
72
+ raise FileNotFoundError(f"Credentials file not found: {service_account_credentials}")
73
+ return get_oauth_credentials(service_account_credentials=service_account_credentials, scopes=SCOPES)
74
+
75
+
76
+ def get_service_account_email():
77
+ """
78
+ Get the service account email from the credentials file.
79
+ Useful for sharing files/folders with the service account.
80
+
81
+ Returns:
82
+ str: Service account email address
83
+
84
+ Raises:
85
+ FileNotFoundError: If service-account-credentials.json is not found
86
+ KeyError: If email is not found in the credentials file
87
+ """
88
+ import json
89
+
90
+ if not os.path.exists(SERVICE_ACCOUNT_CREDENTIALS):
91
+ raise FileNotFoundError(
92
+ f"Service account credentials file '{SERVICE_ACCOUNT_CREDENTIALS}' not found."
93
+ )
94
+
95
+ with open(SERVICE_ACCOUNT_CREDENTIALS, 'r') as f:
96
+ creds_data = json.load(f)
97
+
98
+ email = creds_data.get('client_email')
99
+ if not email:
100
+ raise KeyError("'client_email' not found in service account credentials file")
101
+
102
+ return email
103
+
@@ -0,0 +1,96 @@
1
+ """Command-line interface for gslides_automator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Callable
8
+
9
+
10
+ def _run_generate_data(args: argparse.Namespace) -> int:
11
+ """Entrypoint for the `generate-data` subcommand."""
12
+ from .generate_data import generate_data, get_oauth_credentials, resolve_layout
13
+
14
+ creds = get_oauth_credentials(service_account_credentials=args.service_account_credentials)
15
+ layout = resolve_layout(args.shared_drive_url, creds)
16
+ generate_data(
17
+ creds=creds,
18
+ layout=layout,
19
+ )
20
+ return 0
21
+
22
+
23
+ def _run_generate_report(args: argparse.Namespace) -> int:
24
+ """Entrypoint for the `generate-report` subcommand."""
25
+ from .generate_report import generate_report, get_oauth_credentials, resolve_layout
26
+
27
+ creds = get_oauth_credentials(service_account_credentials=args.service_account_credentials)
28
+ layout = resolve_layout(args.shared_drive_url, creds)
29
+ generate_report(
30
+ creds=creds,
31
+ layout=layout,
32
+ )
33
+ return 0
34
+
35
+
36
+ def _build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(
38
+ prog="gslides_automator",
39
+ description="Utilities for generating data and reports from Google Drive and Sheets.",
40
+ )
41
+ subparsers = parser.add_subparsers(dest="command", required=True)
42
+
43
+ gen_parser = subparsers.add_parser(
44
+ "generate-data",
45
+ help="Generate L1 data from the shared drive layout.",
46
+ )
47
+ gen_parser.add_argument(
48
+ "--shared-drive-url",
49
+ required=True,
50
+ help="Shared Drive root URL or ID that contains L0/L1 data and templates.",
51
+ )
52
+ gen_parser.add_argument(
53
+ "--service-account-credentials",
54
+ default=None,
55
+ help="Path to the service account JSON key file. Defaults to service-account-credentials.json in the project root.",
56
+ )
57
+ gen_parser.set_defaults(func=_run_generate_data)
58
+
59
+ report_parser = subparsers.add_parser(
60
+ "generate-report",
61
+ help="Generate Google Slides reports from L1 data.",
62
+ )
63
+ report_parser.add_argument(
64
+ "--shared-drive-file",
65
+ required=True,
66
+ help="Shared Drive root URL or ID that contains L1/L2 data, templates, and entities.",
67
+ )
68
+ report_parser.add_argument(
69
+ "--credentials",
70
+ dest="credentials",
71
+ default=None,
72
+ help="Path to the service account JSON key file. Defaults to service-account-credentials.json in the project root.",
73
+ )
74
+ report_parser.set_defaults(func=_run_generate_report)
75
+
76
+ return parser
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ parser = _build_parser()
81
+ args = parser.parse_args(argv)
82
+
83
+ handler: Callable[[argparse.Namespace], int] | None = getattr(args, "func", None)
84
+ if handler is None:
85
+ parser.print_help()
86
+ return 1
87
+
88
+ try:
89
+ return handler(args)
90
+ except Exception as exc: # pragma: no cover - CLI guardrail
91
+ print(f"Error: {exc}", file=sys.stderr)
92
+ return 1
93
+
94
+
95
+ if __name__ == "__main__": # pragma: no cover
96
+ sys.exit(main())
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import csv
5
+ import io
6
+ import re
7
+ from typing import Dict, Iterable, List, Optional, Sequence, Set
8
+
9
+ from googleapiclient.discovery import build
10
+ from googleapiclient.http import MediaIoBaseDownload
11
+
12
+
13
+ @dataclass
14
+ class DriveLayout:
15
+ root_id: str
16
+ l0_data_id: str
17
+ l1_data_id: str
18
+ l2_report_id: str
19
+ templates_id: str
20
+ data_template_id: str
21
+ report_template_id: str
22
+ entities_csv_id: str
23
+
24
+
25
+ def _extract_id_from_url(shared_drive_url: str) -> str:
26
+ """
27
+ Extract a Drive folder/file ID from a shared Drive URL or raw ID.
28
+ """
29
+ if re.fullmatch(r"[A-Za-z0-9_\-]+", shared_drive_url):
30
+ return shared_drive_url
31
+
32
+ patterns = [
33
+ r"/folders/([A-Za-z0-9_\-]+)",
34
+ r"[?&]id=([A-Za-z0-9_\-]+)",
35
+ ]
36
+ for pattern in patterns:
37
+ match = re.search(pattern, shared_drive_url)
38
+ if match:
39
+ return match.group(1)
40
+ raise ValueError("Could not extract Drive folder ID from URL. Pass a folder link or ID.")
41
+
42
+
43
+ def _find_child_by_name(
44
+ drive_service,
45
+ parent_id: str,
46
+ names: str | Sequence[str],
47
+ mime_type: str | None = None,
48
+ ) -> str:
49
+ """
50
+ Locate a child by exact name (supports multiple candidate names).
51
+ """
52
+ candidates: Iterable[str] = [names] if isinstance(names, str) else names
53
+ mime_clause = f" and mimeType='{mime_type}'" if mime_type else ""
54
+
55
+ for name in candidates:
56
+ query = f"'{parent_id}' in parents and name='{name}' and trashed=false{mime_clause}"
57
+ result = drive_service.files().list(
58
+ q=query,
59
+ fields="files(id,name,mimeType)",
60
+ supportsAllDrives=True,
61
+ includeItemsFromAllDrives=True,
62
+ pageSize=5,
63
+ ).execute()
64
+ files = result.get("files", [])
65
+ if files:
66
+ return files[0]["id"]
67
+
68
+ raise FileNotFoundError(
69
+ f"Could not find any of {list(candidates)} inside parent id {parent_id}"
70
+ )
71
+
72
+
73
+ def resolve_layout(shared_drive_url: str, creds) -> DriveLayout:
74
+ """
75
+ Discover the standard folder/file layout starting from the shared drive URL.
76
+ """
77
+ drive_service = build("drive", "v3", credentials=creds)
78
+ root_id = _extract_id_from_url(shared_drive_url)
79
+
80
+ l0_id = _find_child_by_name(drive_service, root_id, "L0-Data", mime_type="application/vnd.google-apps.folder")
81
+ l1_id = _find_child_by_name(drive_service, root_id, "L1-Data", mime_type="application/vnd.google-apps.folder")
82
+ l2_id = _find_child_by_name(drive_service, root_id, "L2-Reports", mime_type="application/vnd.google-apps.folder")
83
+ templates_id = _find_child_by_name(drive_service, root_id, "Templates", mime_type="application/vnd.google-apps.folder")
84
+
85
+ data_template_id = _find_child_by_name(
86
+ drive_service,
87
+ templates_id,
88
+ names=("data-template.gsheet", "data-template"),
89
+ mime_type="application/vnd.google-apps.spreadsheet",
90
+ )
91
+ report_template_id = _find_child_by_name(
92
+ drive_service,
93
+ templates_id,
94
+ names=("report-template.gslide", "report-template"),
95
+ mime_type="application/vnd.google-apps.presentation",
96
+ )
97
+ entities_csv_id = _find_child_by_name(
98
+ drive_service,
99
+ root_id,
100
+ names=("entities.csv", "entities"),
101
+ mime_type="text/csv",
102
+ )
103
+
104
+ return DriveLayout(
105
+ root_id=root_id,
106
+ l0_data_id=l0_id,
107
+ l1_data_id=l1_id,
108
+ l2_report_id=l2_id,
109
+ templates_id=templates_id,
110
+ data_template_id=data_template_id,
111
+ report_template_id=report_template_id,
112
+ entities_csv_id=entities_csv_id,
113
+ )
114
+
115
+
116
+ def load_entities(entities_csv_id: str, creds) -> List[str]:
117
+ """
118
+ Download entities.csv and return entity names (first column) where the adjacent
119
+ `generate` column (second column) is exactly `Y`.
120
+ """
121
+ drive_service = build("drive", "v3", credentials=creds)
122
+ request = drive_service.files().get_media(fileId=entities_csv_id, supportsAllDrives=True)
123
+ buffer = io.BytesIO()
124
+ downloader = MediaIoBaseDownload(buffer, request)
125
+ done = False
126
+ while not done:
127
+ status, done = downloader.next_chunk()
128
+ buffer.seek(0)
129
+ content = buffer.read().decode("utf-8")
130
+
131
+ reader = csv.reader(io.StringIO(content))
132
+ entities: List[str] = []
133
+ for row in reader:
134
+ if not row:
135
+ continue
136
+
137
+ name = row[0].strip()
138
+ generate_flag = row[1].strip() if len(row) > 1 else ""
139
+
140
+ if not name:
141
+ continue
142
+
143
+ # Skip header row
144
+ if not entities and name.lower().startswith("entity"):
145
+ continue
146
+
147
+ # Only include rows explicitly marked for generation
148
+ if generate_flag == "Y":
149
+ entities.append(name)
150
+ return entities
151
+
152
+ def _parse_slides_value(slides_value: str) -> Optional[Set[int]]:
153
+ """
154
+ Parse a slides column value into a set of slide numbers.
155
+
156
+ Accepts comma-separated numbers and ranges (e.g., "1,2-4,6").
157
+ Returns None when the value is blank or no valid numbers are found
158
+ to indicate that all slides should be processed.
159
+ """
160
+ if not slides_value:
161
+ return None
162
+
163
+ slides: Set[int] = set()
164
+ for part in slides_value.split(","):
165
+ part = part.strip()
166
+ if not part:
167
+ continue
168
+
169
+ if "-" in part:
170
+ bounds = part.split("-", 1)
171
+ if len(bounds) != 2:
172
+ continue
173
+ try:
174
+ start = int(bounds[0].strip())
175
+ end = int(bounds[1].strip())
176
+ except ValueError:
177
+ continue
178
+
179
+ if start > end:
180
+ start, end = end, start
181
+
182
+ for num in range(start, end + 1):
183
+ if num > 0:
184
+ slides.add(num)
185
+ else:
186
+ try:
187
+ value = int(part)
188
+ except ValueError:
189
+ continue
190
+ if value > 0:
191
+ slides.add(value)
192
+
193
+ return slides or None
194
+
195
+
196
+ def load_entities_with_slides(entities_csv_id: str, creds) -> Dict[str, Optional[Set[int]]]:
197
+ """
198
+ Download entities.csv and return a mapping of entity name to requested slide
199
+ numbers for rows marked with generate=Y. A value of None means all slides.
200
+ """
201
+ drive_service = build("drive", "v3", credentials=creds)
202
+ request = drive_service.files().get_media(fileId=entities_csv_id, supportsAllDrives=True)
203
+ buffer = io.BytesIO()
204
+ downloader = MediaIoBaseDownload(buffer, request)
205
+ done = False
206
+ while not done:
207
+ status, done = downloader.next_chunk()
208
+ buffer.seek(0)
209
+ content = buffer.read().decode("utf-8")
210
+
211
+ reader = csv.reader(io.StringIO(content))
212
+ entities: Dict[str, Optional[Set[int]]] = {}
213
+ for row in reader:
214
+ if not row:
215
+ continue
216
+
217
+ name = row[0].strip()
218
+ generate_flag = row[1].strip() if len(row) > 1 else ""
219
+ slides_value = row[2].strip() if len(row) > 2 else ""
220
+
221
+ if not name:
222
+ continue
223
+
224
+ # Skip header row
225
+ if not entities and name.lower().startswith("entity"):
226
+ continue
227
+
228
+ if generate_flag == "Y":
229
+ slides = _parse_slides_value(slides_value)
230
+ entities[name] = slides
231
+
232
+ return entities
233
+