atlasdocs-indico 0.9.0__tar.gz

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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: atlasdocs-indico
3
+ Version: 0.9.0
4
+ Summary: Indico integration scripts for atlasdocs-theme
5
+ Author-email: Jason Oliver <jason.oliver@cern.ch>
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: python-dotenv>=1.0
9
+ Requires-Dist: requests>=2.28
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def __getattr__(name: str):
5
+ if name == "create_indico_pages":
6
+ from .prepare_indico_pages import create_indico_pages
7
+ return create_indico_pages
8
+ if name == "create_indico_nav":
9
+ from .prepare_indico_nav import create_indico_nav
10
+ return create_indico_nav
11
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
12
+
13
+
14
+ __all__ = ["create_indico_pages", "create_indico_nav"]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Fetch Indico category events and save them as JSON files.
5
+
6
+ Reads which categories to fetch from indico.toml in the hub directory.
7
+ Writes one file per category:
8
+ data/indico/category/{id}/category_{id}.json
9
+
10
+ New events are merged with any existing file (most recent event wins on URL clash).
11
+ Requires an Indico API token via --token or the INDICO_API_TOKEN env variable.
12
+
13
+ Usage examples
14
+ --------------
15
+ Fetch all categories from indico.toml (no args needed):
16
+ python -m atlasdocs_indico.fetch_indico
17
+
18
+ Override categories from the command line:
19
+ python -m atlasdocs_indico.fetch_indico --category 1234
20
+ python -m atlasdocs_indico.fetch_indico --category 1234 --category 5678
21
+
22
+ Limit the date range:
23
+ python -m atlasdocs_indico.fetch_indico --years 2
24
+ python -m atlasdocs_indico.fetch_indico --start-date 2023-01-01
25
+
26
+ Pixi (args after --):
27
+ pixi run fetch-indico
28
+ pixi run fetch-indico -- --category 1234
29
+ """
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import sys
35
+ import tomllib
36
+ from pathlib import Path
37
+
38
+ from dotenv import load_dotenv
39
+ load_dotenv(Path('.env').resolve())
40
+
41
+ # ── Configuration ──────────────────────────────────────────────────────────────
42
+
43
+ INDICO_SITE = "https://indico.cern.ch"
44
+ DATA_DIR = "data/indico/category"
45
+ DEFAULT_YEARS = 2
46
+ INDICO_CONFIG_FILE = "indico.toml"
47
+
48
+
49
+ # ── Config loading ─────────────────────────────────────────────────────────────
50
+
51
+ def load_indico_config(hub_dir: str = ".") -> dict:
52
+ """Load indico.toml. Returns {categories: [{id, label}], site, years}."""
53
+ config_path = Path(hub_dir) / INDICO_CONFIG_FILE
54
+ if not config_path.exists():
55
+ return {"categories": [], "site": INDICO_SITE, "years": DEFAULT_YEARS}
56
+ with open(config_path, "rb") as f:
57
+ data = tomllib.load(f)
58
+ return {
59
+ "categories": data.get("categories", []),
60
+ "site": data.get("site", INDICO_SITE),
61
+ "years": int(data.get("years", DEFAULT_YEARS)),
62
+ }
63
+
64
+
65
+ # ── Fetch ──────────────────────────────────────────────────────────────────────
66
+
67
+ def fetch_category(
68
+ category_id: int,
69
+ api_token: str,
70
+ site: str = INDICO_SITE,
71
+ years: int = DEFAULT_YEARS,
72
+ start_date: str | None = None,
73
+ end_date: str | None = None,
74
+ ) -> list[dict]:
75
+ from .indicoapi.indico import Indico
76
+
77
+ indico = Indico(
78
+ category_id=category_id,
79
+ api_token=api_token,
80
+ site=site,
81
+ years=years,
82
+ start_date=start_date,
83
+ end_date=end_date,
84
+ )
85
+ meetings = indico.get_meetings(include=None, exclude=None)
86
+
87
+ events = []
88
+ for event in meetings:
89
+ contributions = [
90
+ {
91
+ "title": c.title,
92
+ "url": c.url,
93
+ "speakers": c.speakers,
94
+ "minutes": c.minutes,
95
+ "start_time": c.start_time,
96
+ "attachments": c.attachments,
97
+ }
98
+ for c in event.contributions
99
+ ]
100
+ events.append({
101
+ "url": event.url,
102
+ "date": event.date,
103
+ "title": event.title,
104
+ "minutes_url": event.minutes_url,
105
+ "contributions": contributions,
106
+ })
107
+
108
+ return events
109
+
110
+
111
+ # ── Prepare ────────────────────────────────────────────────────────────────────
112
+
113
+ def save_category(category_id: int | str, events: list[dict], data_dir: str) -> Path:
114
+ cat_id = str(category_id)
115
+ out_path = Path(data_dir) / cat_id / f"category_{cat_id}.json"
116
+ out_path.parent.mkdir(parents=True, exist_ok=True)
117
+
118
+ existing: dict[str, dict] = {}
119
+ if out_path.exists():
120
+ try:
121
+ with open(out_path) as f:
122
+ for ev in json.load(f):
123
+ if ev.get("url"):
124
+ existing[ev["url"]] = ev
125
+ except Exception:
126
+ pass
127
+
128
+ n_before = len(existing)
129
+ for ev in events:
130
+ if ev.get("url"):
131
+ existing[ev["url"]] = ev
132
+
133
+ merged = sorted(existing.values(), key=lambda e: e.get("date", ""), reverse=True)
134
+ with open(out_path, "w") as f:
135
+ json.dump(merged, f, separators=(",", ":"))
136
+
137
+ new_count = len(merged) - n_before
138
+ new_str = f"+{new_count} new" if new_count else "no change"
139
+ print(f"[indico] [{cat_id}] {len(merged)} events ({new_str}) → {out_path}")
140
+ return out_path
141
+
142
+
143
+ def fetch_indico(
144
+ category_ids: list[int],
145
+ api_token: str,
146
+ site: str = INDICO_SITE,
147
+ years: int = DEFAULT_YEARS,
148
+ start_date: str | None = None,
149
+ end_date: str | None = None,
150
+ data_dir: str = DATA_DIR,
151
+ hub_dir: str = ".",
152
+ ) -> None:
153
+ resolved_data_dir = str(Path(hub_dir) / data_dir)
154
+ errors = []
155
+ for cat_id in category_ids:
156
+ try:
157
+ events = fetch_category(
158
+ category_id=cat_id,
159
+ api_token=api_token,
160
+ site=site,
161
+ years=years,
162
+ start_date=start_date,
163
+ end_date=end_date,
164
+ )
165
+ save_category(cat_id, events, data_dir=resolved_data_dir)
166
+ except Exception as e:
167
+ print(f"[indico] ERROR: category {cat_id}: {e}")
168
+ errors.append(cat_id)
169
+
170
+ if errors:
171
+ print(f"[indico] Done with {len(errors)} error(s): {errors}")
172
+ sys.exit(1)
173
+ n = len(category_ids)
174
+ print(f"[indico] Fetched {n} {'category' if n == 1 else 'categories'}")
175
+
176
+
177
+ def main() -> None:
178
+ parser = argparse.ArgumentParser(
179
+ description="Fetch Indico category events and save as JSON.",
180
+ formatter_class=argparse.RawDescriptionHelpFormatter,
181
+ epilog=__doc__.split("Usage examples")[1] if __doc__ and "Usage examples" in __doc__ else "",
182
+ )
183
+ parser.add_argument(
184
+ "--category", "-c",
185
+ metavar="ID",
186
+ action="append",
187
+ type=int,
188
+ help="Category ID to fetch (repeat for multiple: -c 1234 -c 5678). "
189
+ "Defaults to all categories listed in indico.toml.",
190
+ )
191
+ parser.add_argument("--token", help="Indico API token (default: $INDICO_API_TOKEN)")
192
+ parser.add_argument("--site", default=None, help=f"Indico base URL (default: from indico.toml or {INDICO_SITE})")
193
+ parser.add_argument("--years", type=int, default=None, help=f"Years of history to fetch (default: from indico.toml or {DEFAULT_YEARS})")
194
+ parser.add_argument("--start-date", metavar="YYYY-MM-DD", help="Override start date")
195
+ parser.add_argument("--end-date", metavar="YYYY-MM-DD", help="Override end date")
196
+ parser.add_argument("--data-dir", default=DATA_DIR, help=f"Output directory (default: {DATA_DIR})")
197
+
198
+ args = parser.parse_args()
199
+
200
+ hub_dir = str(Path.cwd())
201
+ config = load_indico_config(hub_dir)
202
+
203
+ api_token = args.token or os.environ.get("INDICO_API_TOKEN")
204
+ if not api_token:
205
+ parser.error("API token required — use --token or set INDICO_API_TOKEN")
206
+
207
+ # --category overrides config; fall back to all categories in indico.toml
208
+ if args.category:
209
+ category_ids = args.category
210
+ else:
211
+ category_ids = [int(c["id"]) for c in config["categories"]]
212
+ if not category_ids:
213
+ parser.error("No categories specified — pass --category or add entries to indico.toml")
214
+
215
+ site = args.site or config["site"]
216
+ years = args.years or config["years"]
217
+
218
+ fetch_indico(
219
+ category_ids=category_ids,
220
+ api_token=api_token,
221
+ site=site,
222
+ years=years,
223
+ start_date=args.start_date,
224
+ end_date=args.end_date,
225
+ data_dir=args.data_dir,
226
+ hub_dir=hub_dir,
227
+ )
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def __getattr__(name: str):
5
+ if name == "Category":
6
+ from .category import Category
7
+ return Category
8
+ if name == "Event":
9
+ from .event import Event
10
+ return Event
11
+ if name == "Contribution":
12
+ from .contribution import Contribution
13
+ return Contribution
14
+ if name == "Indico":
15
+ from .indico import Indico
16
+ return Indico
17
+ if name == "BearerAuth":
18
+ from .indicorequests import BearerAuth
19
+ return BearerAuth
20
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
21
+
22
+
23
+ __all__ = ["Category", "Event", "Contribution", "Indico", "BearerAuth"]
@@ -0,0 +1,26 @@
1
+ """Category data model for Indico API."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ # ── Configuration ──────────────────────────────────────────────────────────────
6
+
7
+ INDICO_BASE_URL = "https://indico.cern.ch"
8
+
9
+
10
+ @dataclass
11
+ class Category:
12
+ """Represents an Indico category."""
13
+
14
+ category_id: str
15
+ name: str
16
+ num_events: int
17
+ is_protected: bool = False
18
+ parent_id: str = None
19
+ url: str = None
20
+ immediate_children: list = None
21
+
22
+ def __post_init__(self):
23
+ if self.immediate_children is None:
24
+ self.immediate_children = []
25
+ if self.url is None:
26
+ self.url = f"{INDICO_BASE_URL}/category/{self.category_id}/"
@@ -0,0 +1,20 @@
1
+ """Contribution data model for Indico API - pure data, no rendering."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Contribution:
8
+ """Represents an Indico contribution with pure data (no markdown rendering)."""
9
+
10
+ title: str
11
+ url: str
12
+ speakers: list[str] = field(default_factory=list)
13
+ minutes: dict = None
14
+ start_time: str = None
15
+ end_time: str = None
16
+ duration: int = None
17
+ attachments: list[dict] = field(default_factory=list)
18
+ description: str = None
19
+ keywords: list[str] = field(default_factory=list)
20
+ contribution_type: str = None
@@ -0,0 +1,30 @@
1
+ """Event data model for Indico API - pure data, no rendering."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Event:
8
+ """Represents an Indico event with pure data (no markdown rendering)."""
9
+
10
+ url: str
11
+ date: str
12
+ title: str
13
+ contributions: list[dict]
14
+ time: str = None
15
+ end_date: str = None
16
+ end_time: str = None
17
+ room: str = None
18
+ location: str = None
19
+ minutes_url: str = None
20
+ created_dt: str = None
21
+ event_type: str = None
22
+ description: str = None
23
+ chairpersons: list[dict] = field(default_factory=list)
24
+ label: dict = None
25
+ category: str = None
26
+ category_id: int = None
27
+
28
+ def __lt__(self, other):
29
+ """Sort by date (newest first)."""
30
+ return self.date > other.date
@@ -0,0 +1,262 @@
1
+ import datetime
2
+ import functools
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+
8
+ from . import indicorequests as ireq
9
+ from .contribution import Contribution
10
+ from .event import Event
11
+
12
+ REQUEST_DELAY = 0 # seconds between pagination calls
13
+
14
+
15
+ def _paginate(fetch_fn, page_size: int, log_prefix: str) -> tuple[list, int]:
16
+ """Returns (results, number_of_api_calls).
17
+
18
+ Stops when Indico returns an empty page. With detail=contributions the limit
19
+ controls a contribution offset (not a hard cap on returned events), so pages
20
+ routinely come back with fewer than page_size events even when more exist.
21
+ """
22
+ all_results, offset, page_num = [], 0, 1
23
+ while True:
24
+ page = fetch_fn(offset=offset)
25
+ all_results.extend(page)
26
+ if not page:
27
+ break
28
+ offset += page_size
29
+ page_num += 1
30
+ if REQUEST_DELAY > 0:
31
+ time.sleep(REQUEST_DELAY)
32
+ return all_results, page_num
33
+
34
+
35
+ @dataclass
36
+ class Indico:
37
+ # Accepts a single int or a list of ints; always stored as category_ids list.
38
+ category_id: int | list[int]
39
+ api_token: str = None
40
+ site: str = "https://indico.cern.ch"
41
+ years: int = 1
42
+ # Explicit date overrides — take priority over years when provided (YYYY-MM-DD).
43
+ start_date: str | None = None
44
+ end_date: str | None = None
45
+
46
+ def __post_init__(self):
47
+ if self.api_token is None:
48
+ self.api_token = os.environ.get("INDICO_API_TOKEN", None)
49
+ if self.api_token is None:
50
+ msg = "Please provide an API token or set INDICO_API_TOKEN in your env"
51
+ raise ValueError(msg)
52
+
53
+ # Normalise to list regardless of whether a single int was supplied.
54
+ if isinstance(self.category_id, int):
55
+ self.category_ids: list[int] = [self.category_id]
56
+ else:
57
+ self.category_ids = list(self.category_id)
58
+
59
+ self.req_kwargs = {"site": self.site, "api_token": self.api_token}
60
+
61
+ today = datetime.datetime.now(tz=datetime.timezone.utc).date()
62
+ if self.start_date is None:
63
+ self.start_date = (
64
+ today - datetime.timedelta(days=365 * self.years)
65
+ ).strftime("%Y-%m-%d")
66
+ if self.end_date is None:
67
+ self.end_date = (today + datetime.timedelta(days=15)).strftime("%Y-%m-%d")
68
+
69
+ # Legacy single-category URL points at the primary (first) category.
70
+ self.url = f"{self.site}/category/{self.category_ids[0]}/"
71
+
72
+ all_events: list = []
73
+ self.api_call_count: int = 0
74
+
75
+ for cat_id in self.category_ids:
76
+ fetch_fn = functools.partial(
77
+ ireq.request_category_contributions,
78
+ category_id=cat_id,
79
+ start_date=self.start_date,
80
+ end_date=self.end_date,
81
+ limit=ireq.CONTRIBUTIONS_PAGE_SIZE,
82
+ **self.req_kwargs,
83
+ )
84
+ events, calls = _paginate(
85
+ fetch_fn=fetch_fn,
86
+ page_size=ireq.CONTRIBUTIONS_PAGE_SIZE,
87
+ log_prefix=str(cat_id),
88
+ )
89
+ all_events.extend(events)
90
+ self.api_call_count += calls
91
+
92
+ if len(self.category_ids) > 1 and REQUEST_DELAY > 0:
93
+ time.sleep(REQUEST_DELAY)
94
+
95
+ self.events = all_events
96
+
97
+ @staticmethod
98
+ def filter_events(
99
+ results: list,
100
+ include: list[str] | None = None,
101
+ exclude: list[str] | None = None,
102
+ ) -> list:
103
+ if include is None:
104
+ include = []
105
+ if exclude is None:
106
+ exclude = []
107
+ include = [x.lower() for x in include]
108
+ exclude = [x.lower() for x in exclude]
109
+
110
+ if not include:
111
+ return results
112
+
113
+ return [
114
+ r
115
+ for r in results
116
+ if any(x in r["title"].lower() for x in include)
117
+ and not any(x in r["title"].lower() for x in exclude)
118
+ and not (
119
+ "label" in r
120
+ and r["label"] is not None
121
+ and r["label"].get("title").lower() == "cancelled"
122
+ )
123
+ ]
124
+
125
+ def get_meetings(
126
+ self,
127
+ include: list[str] | None = None,
128
+ exclude: list[str] | None = None,
129
+ events_override: list | None = None,
130
+ ) -> list[Event]:
131
+ """Return filtered meetings as typed Event objects with Contribution objects."""
132
+ if exclude is None:
133
+ exclude = []
134
+ exclude += ["cancelled"]
135
+ raw_events = events_override if events_override is not None else self.events
136
+ filtered = self.filter_events(raw_events, include=include, exclude=exclude)
137
+
138
+ results = []
139
+ for event in filtered:
140
+ contributions = []
141
+ for c in event.get("contributions", []):
142
+ attachments = []
143
+ for folder in c.get("folders", []):
144
+ for a in folder.get("attachments", []):
145
+ attachments.append(
146
+ {
147
+ "url": a.get("download_url", ""),
148
+ "filename": a.get(
149
+ "filename", a.get("title", "unknown")
150
+ ),
151
+ "size": a.get("size"),
152
+ "content_type": a.get("content_type"),
153
+ "title": a.get("title"),
154
+ }
155
+ )
156
+ contributions.append(
157
+ Contribution(
158
+ title=c.get("title", ""),
159
+ url=c.get("url", ""),
160
+ speakers=c.get("speakers", []),
161
+ minutes=c.get("note"),
162
+ start_time=(c.get("startDate") or {}).get("time"),
163
+ end_time=(c.get("endDate") or {}).get("time"),
164
+ duration=c.get("duration"),
165
+ attachments=attachments,
166
+ description=c.get("description"),
167
+ keywords=c.get("keywords") or [],
168
+ contribution_type=(c.get("type") or {}).get("name") if c.get("type") else None,
169
+ )
170
+ )
171
+ results.append(
172
+ Event(
173
+ url=event.get("url", ""),
174
+ date=(event.get("startDate") or {}).get("date", ""),
175
+ title=event.get("title", ""),
176
+ contributions=contributions,
177
+ location=event.get("location"),
178
+ room=event.get("room"),
179
+ time=(event.get("startDate") or {}).get("time"),
180
+ end_date=(event.get("endDate") or {}).get("date"),
181
+ end_time=(event.get("endDate") or {}).get("time"),
182
+ minutes_url=(
183
+ event["note"]["url"]
184
+ if event.get("note") and "url" in event["note"]
185
+ else None
186
+ ),
187
+ created_dt=event.get("createdDT"),
188
+ event_type=event.get("type"),
189
+ description=event.get("description"),
190
+ chairpersons=event.get("chairpersons") or [],
191
+ label=event.get("label"),
192
+ category=event.get("category"),
193
+ category_id=event.get("categoryId"),
194
+ )
195
+ )
196
+
197
+ return sorted(results, key=lambda e: e.date, reverse=True)
198
+
199
+ def get_meetings_for_author(self, author: str) -> list[Event]:
200
+ """Return meetings where all tokens of `author` appear in a speaker's full_name."""
201
+ tokens = author.lower().split()
202
+ matching = []
203
+ for event in self.events:
204
+ for contrib in event.get("contributions", []):
205
+ if any(
206
+ all(
207
+ t in (s.get("fullName") or s.get("full_name") or "").lower()
208
+ for t in tokens
209
+ )
210
+ for s in contrib.get("speakers", [])
211
+ ):
212
+ matching.append(event)
213
+ break
214
+ return self.get_meetings(events_override=matching)
215
+
216
+ def get_ical(
217
+ self,
218
+ extra_category_ids: list[int] | None = None,
219
+ start_date: str | None = None,
220
+ end_date: str | None = None,
221
+ ) -> str:
222
+ """Return a combined iCal string for all stored categories (+ any extras).
223
+
224
+ Paginates automatically if the range contains > 1000 events.
225
+ Defaults to 60 days in the past through 15 days in the future.
226
+ """
227
+ today = datetime.datetime.now(tz=datetime.timezone.utc).date()
228
+ if start_date is None:
229
+ start_date = (today - datetime.timedelta(days=60)).strftime("%Y-%m-%d")
230
+ if end_date is None:
231
+ end_date = (today + datetime.timedelta(days=15)).strftime("%Y-%m-%d")
232
+ ids = self.category_ids + (extra_category_ids or [])
233
+ pages = []
234
+ offset = 0
235
+ page_num = 1
236
+ while True:
237
+ page = ireq.request_category_ical(
238
+ category_ids=ids,
239
+ start_date=start_date,
240
+ end_date=end_date,
241
+ offset=offset,
242
+ **self.req_kwargs,
243
+ )
244
+ n_events = page.count("BEGIN:VEVENT")
245
+ pages.append(page)
246
+ if n_events < ireq.ICAL_PAGE_SIZE:
247
+ break
248
+ offset += ireq.ICAL_PAGE_SIZE
249
+ page_num += 1
250
+ if REQUEST_DELAY > 0:
251
+ time.sleep(REQUEST_DELAY)
252
+
253
+ return _merge_ical_pages(pages)
254
+
255
+
256
+ def _merge_ical_pages(pages: list[str]) -> str:
257
+ if not pages:
258
+ return ""
259
+ all_text = "\n".join(pages)
260
+ events = re.findall(r"BEGIN:VEVENT.*?END:VEVENT", all_text, re.DOTALL)
261
+ header = pages[0].split("BEGIN:VEVENT")[0].rstrip()
262
+ return "\n".join([header] + events + ["END:VCALENDAR"])