diff --git a/examples/python/ess-messages/.env.example b/examples/python/ess-messages/.env.example new file mode 100644 index 0000000..0d54656 --- /dev/null +++ b/examples/python/ess-messages/.env.example @@ -0,0 +1,13 @@ +# Outlook (Microsoft Graph) +# CLIENT_ID: Microsoft Entra app registration (e.g., the "Office Desktop Apps" public client) +CLIENT_ID= +# TENANT_ID: your organization's Microsoft Entra (Azure AD) tenant ID +TENANT_ID= + +# Webex OAuth integration (from https://developer.webex.com/my-apps) +WEBEX_CLIENT_ID= +WEBEX_CLIENT_SECRET= + +# Or use a personal access token directly (12-hour TTL) +# https://developer.webex.com/docs/getting-your-personal-access-token +# WEBEX_ACCESS_TOKEN= diff --git a/examples/python/ess-messages/README.md b/examples/python/ess-messages/README.md new file mode 100644 index 0000000..e2fa835 --- /dev/null +++ b/examples/python/ess-messages/README.md @@ -0,0 +1,58 @@ +# ess-messages + +Unified inbox -- aggregates recent email and Webex messages, sorted by date. + +```bash +ess-messages --limit 5 +``` + +``` +[W] bob@example.com 2026-04-10 12:55 (General) + Team sync moved to 1pm. Please use the updated meeting link. + +[E] Alice Smith 2026-04-10 12:43 (Build Health Alert) + Build health alert: latency exceeded threshold for 5 minutes. + +[W] carol@example.com 2026-04-10 12:01 (Slides Space for Monday) + Uploaded the latest deck to the room files tab. + +[E] broker-alerts-noreply@example.com 2026-04-10 08:26 (Shares of Restricted Stock Ve...) + Your restricted stock vesting transaction has been processed. +``` + +Output uses [Activity Streams 2.0](https://www.w3.org/TR/activitystreams-core/) (W3C) format. + +## Installation + +From the workspace root: + +```bash +uv sync --all-packages +``` + +## Auth + +Requires credentials for one or both sources. See: +- [Outlook setup](../../../packages/python/ess-outlook/.env.example) -- `CLIENT_ID`, `TENANT_ID` +- [Webex setup](../../../packages/python/ess-webex/.env.example) -- `WEBEX_CLIENT_ID`/`WEBEX_CLIENT_SECRET` or `WEBEX_ACCESS_TOKEN` + +Copy this example's `.env.example` to `.env` and fill in values, or set the +variables in your shell environment. Never commit `.env` -- it is meant to +hold secrets. + +## Usage + +```bash +ess-messages # Latest 25 from both sources +ess-messages --limit 10 # Limit results +ess-messages --email-only # Only email +ess-messages --webex-only # Only Webex +ess-messages --webex-rooms R1,R2 # Specific Webex rooms +ess-messages --json-output # Activity Streams 2.0 JSON +``` + +## Running Tests + +```bash +uv run pytest examples/python/ess-messages/src/ess_messages/aggregator/test_aggregator.py +``` diff --git a/examples/python/ess-messages/pyproject.toml b/examples/python/ess-messages/pyproject.toml new file mode 100644 index 0000000..044f0f2 --- /dev/null +++ b/examples/python/ess-messages/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "ess-messages" +version = "0.1.0" +description = "Unified inbox -- aggregates email and Webex messages into Activity Streams 2.0" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "activitystreams2>=0.5.0", + "click>=8.1", + "ess-outlook", + "ess-webex", + "python-dotenv>=1.0.0", +] + +[project.scripts] +ess-messages = "ess_messages.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.sources] +ess-outlook = { workspace = true } +ess-webex = { workspace = true } + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_messages"] +exclude = ["**/test_*.py"] diff --git a/examples/python/ess-messages/src/ess_messages/__init__.py b/examples/python/ess-messages/src/ess_messages/__init__.py new file mode 100644 index 0000000..7c68c87 --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/__init__.py @@ -0,0 +1,3 @@ +"""ess-messages -- unified inbox for email and Webex.""" + +__version__ = "0.1.0" diff --git a/examples/python/ess-messages/src/ess_messages/__main__.py b/examples/python/ess-messages/src/ess_messages/__main__.py new file mode 100644 index 0000000..41c332d --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/__main__.py @@ -0,0 +1,15 @@ +"""Entry point for ``python -m ess_messages``.""" + +from dotenv import load_dotenv + +from ess_messages.cli import cli + +load_dotenv() + + +def main() -> None: + cli() # pylint: disable=no-value-for-parameter + + +if __name__ == "__main__": + main() diff --git a/examples/python/ess-messages/src/ess_messages/aggregator/__init__.py b/examples/python/ess-messages/src/ess_messages/aggregator/__init__.py new file mode 100644 index 0000000..3d11125 --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/aggregator/__init__.py @@ -0,0 +1,5 @@ +"""Message aggregation package.""" + +from .aggregator import MessageAggregator + +__all__ = ["MessageAggregator"] diff --git a/examples/python/ess-messages/src/ess_messages/aggregator/aggregator.py b/examples/python/ess-messages/src/ess_messages/aggregator/aggregator.py new file mode 100644 index 0000000..118a9a7 --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/aggregator/aggregator.py @@ -0,0 +1,56 @@ +"""Fetch and merge messages from email and Webex into AS2 activities.""" + +from __future__ import annotations + +from activitystreams2 import Create + +from ..normalize import normalize_email, normalize_webex, sort_key + + +class MessageAggregator: + """Aggregates messages from Outlook and Webex clients.""" + + def __init__(self, outlook_client=None, webex_client=None) -> None: + self._outlook = outlook_client + self._webex = webex_client + + def get_latest( + self, + *, + limit: int = 25, + webex_room_ids: list[str] | None = None, + email_only: bool = False, + webex_only: bool = False, + ) -> list[Create]: + """Fetch, normalize, merge, and sort messages. + + Returns up to *limit* AS2 Create activities, newest first. + """ + activities: list[Create] = [] + + if not webex_only and self._outlook: + activities.extend(self._fetch_emails(limit)) + + if not email_only and self._webex: + activities.extend(self._fetch_webex(limit, webex_room_ids)) + + activities.sort(key=sort_key, reverse=True) + return activities[:limit] + + def _fetch_emails(self, limit: int) -> list[Create]: + messages = self._outlook.list_messages(limit=limit) + return [normalize_email(msg) for msg in messages] + + def _fetch_webex(self, limit: int, room_ids: list[str] | None) -> list[Create]: + if room_ids is None: + rooms = self._webex.list_rooms(max_results=5) + room_ids = [r["id"] for r in rooms] + + activities: list[Create] = [] + per_room = max(limit // len(room_ids), 1) if room_ids else 0 + for room_id in room_ids: + room = self._webex.get_room(room_id) + room_title = room.get("title", "") + messages = self._webex.list_messages(room_id, max_results=per_room) + activities.extend(normalize_webex(msg, room_title) for msg in messages) + return activities diff --git a/examples/python/ess-messages/src/ess_messages/aggregator/test_aggregator.py b/examples/python/ess-messages/src/ess_messages/aggregator/test_aggregator.py new file mode 100644 index 0000000..3945034 --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/aggregator/test_aggregator.py @@ -0,0 +1,114 @@ +# ruff: noqa: PLR2004 -- magic numbers in test assertions are expected literals +"""Tests for MessageAggregator -- mocks both clients.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +from ess_messages.aggregator import MessageAggregator + +_BASE_TIME = datetime(2026, 4, 10, 12, 0, 0, tzinfo=timezone.utc) + + +def _email_msg(subject="Test email", minutes_ago=10): + return { + "id": f"email-{minutes_ago}", + "subject": subject, + "sender_name": "Alice", + "sender_address": "alice@example.com", + "date": _BASE_TIME - timedelta(minutes=minutes_ago), + "is_read": True, + } + + +def _webex_msg(text="Hello", minutes_ago=5): + return { + "id": f"webex-{minutes_ago}", + "room_id": "room-1", + "person_id": "user-1", + "person_email": "bob@example.com", + "text": text, + "markdown": None, + "created": _BASE_TIME - timedelta(minutes=minutes_ago), + } + + +def _make_aggregator(emails=None, webex_msgs=None): + outlook = MagicMock() + outlook.list_messages.return_value = emails or [] + + webex = MagicMock() + webex.list_rooms.return_value = [{"id": "room-1"}] + webex.get_room.return_value = {"title": "General"} + webex.list_messages.return_value = webex_msgs or [] + + return MessageAggregator(outlook_client=outlook, webex_client=webex) + + +class TestGetLatest: + def test_merges_and_sorts_by_date(self): + aggregator = _make_aggregator( + emails=[_email_msg(minutes_ago=10)], + webex_msgs=[_webex_msg(minutes_ago=5)], + ) + results = aggregator.get_latest(limit=10) + + assert len(results) == 2 + assert results[0]["source"] == "webex" + assert results[1]["source"] == "email" + + def test_respects_limit(self): + aggregator = _make_aggregator( + emails=[_email_msg(minutes_ago=i + 1) for i in range(10)], + webex_msgs=[_webex_msg(minutes_ago=i + 1) for i in range(10)], + ) + results = aggregator.get_latest(limit=5) + + assert len(results) == 5 + + def test_email_only(self): + aggregator = _make_aggregator( + emails=[_email_msg()], + webex_msgs=[_webex_msg()], + ) + results = aggregator.get_latest(email_only=True) + + assert len(results) == 1 + assert results[0]["source"] == "email" + + def test_webex_only(self): + aggregator = _make_aggregator( + emails=[_email_msg()], + webex_msgs=[_webex_msg()], + ) + results = aggregator.get_latest(webex_only=True) + + assert len(results) == 1 + assert results[0]["source"] == "webex" + + def test_empty_sources(self): + aggregator = _make_aggregator() + results = aggregator.get_latest() + + assert results == [] + + +class TestNormalization: + def test_email_produces_as2(self): + aggregator = _make_aggregator(emails=[_email_msg(subject="Q1 Report")]) + results = aggregator.get_latest(email_only=True, limit=1) + + activity = results[0] + assert activity["source"] == "email" + assert activity["actor"]["name"] == "Alice" + assert activity["object"]["name"] == "Q1 Report" + + def test_webex_produces_as2(self): + aggregator = _make_aggregator(webex_msgs=[_webex_msg(text="Hey team")]) + results = aggregator.get_latest(webex_only=True, limit=1) + + activity = results[0] + assert activity["source"] == "webex" + assert activity["actor"]["name"] == "bob@example.com" + assert activity["object"]["content"] == "Hey team" diff --git a/examples/python/ess-messages/src/ess_messages/cli.py b/examples/python/ess-messages/src/ess_messages/cli.py new file mode 100644 index 0000000..0ac8a4b --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/cli.py @@ -0,0 +1,117 @@ +"""Click CLI for the unified messages inbox.""" + +from __future__ import annotations + +import json +import logging + +import click +from ess_outlook.graph import GraphClient +from ess_webex.auth import get_access_token +from ess_webex.client import WebexClient + +from .aggregator import MessageAggregator +from .normalize import activity_to_dict + +logger = logging.getLogger(__name__) + + +def _try_outlook() -> GraphClient | None: + """Attempt to initialize the Outlook client.""" + try: + return GraphClient() + except Exception: + logger.debug("Outlook client unavailable", exc_info=True) + return None + + +def _try_webex() -> WebexClient | None: + """Attempt to initialize the Webex client.""" + try: + return WebexClient(access_token=get_access_token()) + except Exception: + logger.debug("Webex client unavailable", exc_info=True) + return None + + +def _get_aggregator() -> MessageAggregator: + """Build a MessageAggregator from available clients.""" + outlook_client = _try_outlook() + webex_client = _try_webex() + + if not outlook_client and not webex_client: + msg = ( + "No message sources available. " + "Configure Outlook (CLIENT_ID/TENANT_ID) " + "and/or Webex (WEBEX_ACCESS_TOKEN or 'ess-webex login')." + ) + raise click.ClickException(msg) + + return MessageAggregator(outlook_client=outlook_client, webex_client=webex_client) + + +def _format_row(activity) -> str: + """Format a single AS2 activity as a multi-line block.""" + src = activity["source"][0].upper() + + actor = activity["actor"] + sender = actor["name"] if "name" in actor else str(actor) + + published = activity["published"] + if hasattr(published, "strftime"): + published = published.strftime("%Y-%m-%d %H:%M") + + obj = activity["object"] + summary = obj["name"] if "name" in obj else "" + summary = summary or "" + + content = obj["content"] if "content" in obj else "" + content = (content or "").replace("\n", " ").strip() + if len(content) > 80: # noqa: PLR2004 -- preview length + content = content[:77] + "..." + + header = f"[{src}] {sender} {published}" + if summary: + header += f" ({summary})" + return f"{header}\n {content}" + + +@click.command() +@click.option("--limit", default=25, show_default=True, help="Max messages") +@click.option("--email-only", is_flag=True, help="Only email.") +@click.option("--webex-only", is_flag=True, help="Only Webex.") +@click.option("--webex-rooms", help="Comma-separated Webex room IDs.") +@click.option("--json-output", is_flag=True, help="Emit AS2 JSON.") +def cli( + limit: int, + email_only: bool, + webex_only: bool, + webex_rooms: str | None, + json_output: bool, +) -> None: + """Show recent messages from email and Webex, sorted by date.""" + aggregator = _get_aggregator() + room_ids = [r.strip() for r in webex_rooms.split(",")] if webex_rooms else None + + try: + activities = aggregator.get_latest( + limit=limit, + email_only=email_only, + webex_only=webex_only, + webex_room_ids=room_ids, + ) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + + if json_output: + click.echo(json.dumps([activity_to_dict(a) for a in activities], indent=2)) + return + + if not activities: + click.echo("No messages found.") + return + + for i, activity in enumerate(activities): + if i > 0: + click.echo() + click.echo(_format_row(activity)) diff --git a/examples/python/ess-messages/src/ess_messages/normalize.py b/examples/python/ess-messages/src/ess_messages/normalize.py new file mode 100644 index 0000000..1f85475 --- /dev/null +++ b/examples/python/ess-messages/src/ess_messages/normalize.py @@ -0,0 +1,63 @@ +"""Normalize email and Webex messages to Activity Streams 2.0.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +from activitystreams2 import Create, Note, Person + + +def _ensure_datetime(value: object) -> datetime: + """Coerce a value to a timezone-aware datetime.""" + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + if isinstance(value, str) and value: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + return datetime.min.replace(tzinfo=timezone.utc) + + +def normalize_email(msg: dict) -> Create: + """Convert an Outlook GraphClient message dict to an AS2 Create activity.""" + actor = Person(name=msg.get("sender_name", "")) + actor["id"] = f"mailto:{msg.get('sender_address', '')}" + + note = Note(content=msg.get("subject", "")) + note["name"] = msg.get("subject", "") + note["published"] = _ensure_datetime(msg.get("date")) + + activity = Create(actor=actor, object=note) + activity["published"] = note["published"] + activity["source"] = "email" + return activity + + +def normalize_webex(msg: dict, room_title: str = "") -> Create: + """Convert a Webex WebexClient message dict to an AS2 Create activity.""" + sender = msg.get("person_email", "") + actor = Person(name=sender) + actor["id"] = f"mailto:{sender}" + + note = Note(content=msg.get("text", "") or msg.get("markdown", "") or "") + note["name"] = room_title + note["published"] = _ensure_datetime(msg.get("created")) + + activity = Create(actor=actor, object=note) + activity["published"] = note["published"] + activity["source"] = "webex" + return activity + + +def activity_to_dict(activity: Create) -> dict: + """Serialize an AS2 activity to a plain dict.""" + return json.loads(str(activity)) + + +def sort_key(activity: Create) -> datetime: + """Return the published datetime for sorting (newest first).""" + return activity["published"] diff --git a/packages/python/ess-dirs/pyproject.toml b/packages/python/ess-dirs/pyproject.toml new file mode 100644 index 0000000..4600975 --- /dev/null +++ b/packages/python/ess-dirs/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "ess-dirs" +version = "0.1.0" +description = "Secure filesystem helpers (write files with restrictive permissions)." +requires-python = ">=3.12,<3.13" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_dirs"] +exclude = ["**/test_*.py"] diff --git a/packages/python/ess-dirs/src/ess_dirs/__init__.py b/packages/python/ess-dirs/src/ess_dirs/__init__.py new file mode 100644 index 0000000..2723267 --- /dev/null +++ b/packages/python/ess-dirs/src/ess_dirs/__init__.py @@ -0,0 +1,5 @@ +"""Secure file utilities.""" + +from .secure import write_secure + +__all__ = ["write_secure"] diff --git a/packages/python/ess-dirs/src/ess_dirs/secure.py b/packages/python/ess-dirs/src/ess_dirs/secure.py new file mode 100644 index 0000000..9d9d410 --- /dev/null +++ b/packages/python/ess-dirs/src/ess_dirs/secure.py @@ -0,0 +1,29 @@ +"""Atomic file writes with restrictive permissions.""" + +from __future__ import annotations + +import os +import tempfile + +_OWNER_RW = 0o600 # owner read/write only -- protects cached secrets + + +def write_secure(path: str, content: str) -> None: + """Write *content* to *path* atomically with owner-only permissions. + + Creates parent directories if needed, writes to a unique temporary file + with ``0o600`` permissions, then atomically replaces the target via + :func:`os.replace`. This prevents other users from reading the file + and avoids partial writes on crash. + """ + dir_path = os.path.dirname(path) or "." + os.makedirs(dir_path, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=dir_path) + try: + os.fchmod(fd, _OWNER_RW) + with os.fdopen(fd, "w") as f: + f.write(content) + os.replace(tmp_path, path) + except BaseException: + os.unlink(tmp_path) + raise diff --git a/packages/python/ess-outlook/.env.example b/packages/python/ess-outlook/.env.example new file mode 100644 index 0000000..03976e7 --- /dev/null +++ b/packages/python/ess-outlook/.env.example @@ -0,0 +1,5 @@ +# Microsoft Entra (Azure AD) app registration +# CLIENT_ID: "Office Desktop Apps" pre-approved on M365 tenant, or your own app registration +CLIENT_ID= +# TENANT_ID: your organization's Azure AD tenant ID +TENANT_ID= diff --git a/packages/python/ess-outlook/README.md b/packages/python/ess-outlook/README.md new file mode 100644 index 0000000..8ca81c0 --- /dev/null +++ b/packages/python/ess-outlook/README.md @@ -0,0 +1,91 @@ +# ess-outlook + +Read and manage Microsoft Outlook emails from the command line via +Microsoft Graph. + +```bash +ess-outlook list --unread-only +``` + +``` +ID Status Sender Date Subject +---------------------------------------------------------------------------------------------------- +12345 alice@example.com 2026-04-09 10:00 Weekly standup +12346 * bob@example.com 2026-04-09 09:30 Q1 Report +``` + +## Installation + +In a `uv` workspace, declare the dependency in your `pyproject.toml`: + +```toml +[project] +dependencies = ["ess-outlook"] + +[tool.uv.sources] +ess-outlook = { workspace = true } +``` + +Then run `uv sync --all-packages` from the workspace root. + +## Prerequisites + +- `CLIENT_ID` and `TENANT_ID` set in the environment (see `.env.example`) + - `CLIENT_ID` -- the application (client) ID from a Microsoft Entra + (Azure AD) app registration with delegated `Mail.ReadWrite` + permission. The well-known "Office Desktop Apps" public client ID + works on most Microsoft 365 tenants without any registration. + - `TENANT_ID` -- your organization's Microsoft Entra tenant ID. +- Python 3.12+ + +## Usage + +```bash +# List recent inbox messages +ess-outlook list + +# Only unread, as JSON +ess-outlook list --unread-only --json-output + +# Read a specific message +ess-outlook read 12345 + +# Mark one message as read +ess-outlook mark-read 12345 + +# Mark all inbox messages as read +ess-outlook mark-read --all + +# Delete a message (prompts for confirmation) +ess-outlook delete 12345 + +# Delete without confirmation +ess-outlook delete 12345 --force +``` + +### Options + +| Command | Options | +| ----------- | ------------------------------------------------------------- | +| `list` | `--limit N`, `--unread-only`, `--folder NAME`, `--json-output`| +| `read` | `MESSAGE_ID` | +| `mark-read` | `MESSAGE_ID` or `--all`, `--folder NAME` | +| `delete` | `MESSAGE_ID`, `--force` | + +## Authentication + +The first run uses MSAL's device-code flow. A browser opens to +`https://microsoft.com/devicelogin`, where you enter the printed code +and sign in with your Microsoft 365 account. The refresh token is +cached under `~/.cache/ess-outlook/` with restrictive permissions +(via `ess-dirs.write_secure`); subsequent runs refresh silently. + +If your tenant blocks device-code flow via Conditional Access, run +the command directly in your terminal session rather than over a +remote shell. + +## Running Tests + +```bash +uv run pytest packages/python/ess-outlook/ +``` diff --git a/packages/python/ess-outlook/docs/applescript-backend.md b/packages/python/ess-outlook/docs/applescript-backend.md new file mode 100644 index 0000000..dda2ab9 --- /dev/null +++ b/packages/python/ess-outlook/docs/applescript-backend.md @@ -0,0 +1,101 @@ +# AppleScript Backend (Removed) + +The original Outlook CLI used AppleScript to interact with Microsoft +Outlook on macOS. This backend was removed in favor of the Microsoft +Graph API. This document preserves what we learned. + +## How It Worked + +Two modules made up the backend: + +### Runner (`runner.py`) + +A thin wrapper around `/usr/bin/osascript` that executed AppleScript +strings as subprocesses. It detected three failure modes from stderr: + +- **"not running"** -- Outlook wasn't open +- **"not allowed" / "permission"** -- macOS denied automation access +- **Everything else** -- generic AppleScript error + +```python +result = subprocess.run( + ["/usr/bin/osascript", "-e", script], + capture_output=True, text=True, timeout=30, check=False, +) +``` + +### Client (`client.py`) + +`OutlookClient` built AppleScript strings dynamically to query +Outlook's object model. Key patterns: + +- **Field delimiter** -- `|||` separated fields in AppleScript output + since commas appear in email content. +- **Inbox resolution** -- Outlook can have multiple "Inbox" folders + (e.g., Exchange + local "On My Computer"). The client iterated all + mail folders named "Inbox" and picked the one with the most messages. +- **Message IDs** -- AppleScript uses numeric integer IDs + (`message id 12345`), unlike Graph API's opaque string IDs. +- **Folder references** -- Built dynamically: `mail folder id {id}` + for inbox, `mail folder "{name}" of default account` for others. + +Example AppleScript for listing messages: + +```applescript +tell application "Microsoft Outlook" + set msgs to (every message of inbox whose is read is false) + repeat with m in msgs + set output to output & (id of m) & "|||" & (subject of m) & "|||" & ... + end repeat + return output +end tell +``` + +## Why We Removed It + +### Security issues + +1. **AppleScript injection via folder names** -- User-controlled + folder names were interpolated directly into AppleScript strings. + A folder name containing quotes could break the script or inject + commands. We added escaping, but the attack surface remained. + +2. **Untyped message IDs** -- The CLI accepted message IDs as + strings, but AppleScript required integers. Without validation, + non-numeric input could inject into `message id {value}` + expressions. We added `int()` casting, but this was a band-aid. + +### Maintenance burden + +- **macOS only** -- Required a running Outlook desktop app and macOS + automation permissions. +- **Fragile parsing** -- Relied on string splitting with `|||` + delimiters; any field containing the delimiter would break parsing. +- **No pagination** -- AppleScript enumeration loaded all messages + into memory. +- **Permission dialogs** -- First run triggered a macOS automation + permission dialog that confused users. + +### Graph API advantages + +- **Cross-platform** -- Works anywhere with network access, no + desktop app needed. +- **Typed responses** -- JSON responses with well-defined schemas. +- **Pagination** -- Built-in `$top`, `$skip`, `$filter` via OData. +- **Token management** -- MSAL handles refresh automatically. +- **No injection risk** -- Parameters passed as query strings, not + interpolated into scripts. + +## Reference + +The original source files were: + +``` +src/ess_outlook/applescript/ +├── __init__.py # Exported OutlookClient, run_applescript +├── runner.py # osascript subprocess wrapper +└── client/ + ├── __init__.py + ├── client.py # OutlookClient with all AppleScript logic + └── test_client.py # Unit tests (mocked osascript calls) +``` diff --git a/packages/python/ess-outlook/docs/office.py b/packages/python/ess-outlook/docs/office.py new file mode 100644 index 0000000..d988c05 --- /dev/null +++ b/packages/python/ess-outlook/docs/office.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Microsoft Graph API auth for a Microsoft Entra (Azure AD) tenant. +Uses the Office Desktop Apps client ID, which is pre-approved on most +Microsoft 365 tenants. + +Usage: +python3 office.py # Get token (silent refresh or device code) +python3 office.py --check # Check if current token is valid, refresh if not + +Token saved to ~/.cache/ess-outlook/graph_token.txt +Refresh token cached to ~/.cache/ess-outlook/graph_token_cache.json +(~90 day TTL, renews on use) +Access token TTL: ~75 minutes (auto-refreshes via cache) +""" + +import base64 +import json +import os +import sys +import time +import webbrowser + +import msal +from dotenv import load_dotenv +from ess_dirs import write_secure + +load_dotenv() + +CLIENT_ID = os.environ["CLIENT_ID"] # Office Desktop Apps client ID +TENANT_ID = os.environ["TENANT_ID"] # Your organization's Entra tenant ID +AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" +SCOPES = ["https://graph.microsoft.com/.default"] +TOKEN_CACHE = os.path.expanduser("~/.cache/ess-outlook/graph_token_cache.json") +TOKEN_FILE = os.path.expanduser("~/.cache/ess-outlook/graph_token.txt") + + +def _token_expired(): + """Check if the saved access token is expired or about to expire (5 min buffer).""" + if not os.path.exists(TOKEN_FILE): + return True + try: + with open(TOKEN_FILE) as f: + token = f.read().strip() + payload = token.split(".")[1] + payload += "=" * (4 - len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + return time.time() > (claims.get("exp", 0) - 300) + except Exception: + return True + + +def _save(cache, token): + write_secure(TOKEN_CACHE, cache.serialize()) + write_secure(TOKEN_FILE, token) + print(token) + + +def get_token(force=False): + cache = msal.SerializableTokenCache() + if os.path.exists(TOKEN_CACHE): + with open(TOKEN_CACHE, "r") as f: + cache.deserialize(f.read()) + + app = msal.PublicClientApplication( + CLIENT_ID, authority=AUTHORITY, token_cache=cache + ) + + accounts = app.get_accounts() + if accounts: + result = app.acquire_token_silent(SCOPES, account=accounts[0]) + if result and "access_token" in result: + _save(cache, result["access_token"]) + print(f"Refreshed token for {accounts[0]['username']}", file=sys.stderr) + return result["access_token"] + + flow = app.initiate_device_flow(scopes=SCOPES) + if "user_code" not in flow: + print(f"Error: {flow.get('error_description', 'unknown')}", file=sys.stderr) + sys.exit(1) + + code = flow["user_code"] + print(f"Opening browser... Enter code: {code}", file=sys.stderr) + webbrowser.open("https://login.microsoft.com/device") + result = app.acquire_token_by_device_flow(flow) + + if "access_token" in result: + _save(cache, result["access_token"]) + user = result.get("id_token_claims", {}).get("preferred_username", "unknown") + print(f"Authenticated as {user}", file=sys.stderr) + return result["access_token"] + else: + print(f"Error: {result.get('error_description', 'unknown')}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if "--check" in sys.argv: + if _token_expired(): + print("Token expired or missing, refreshing...", file=sys.stderr) + get_token() + else: + print("Token valid", file=sys.stderr) + with open(TOKEN_FILE) as f: + print(f.read().strip()) + else: + get_token() diff --git a/packages/python/ess-outlook/pyproject.toml b/packages/python/ess-outlook/pyproject.toml new file mode 100644 index 0000000..6a39385 --- /dev/null +++ b/packages/python/ess-outlook/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "ess-outlook" +version = "0.1.0" +description = "CLI tool for reading and managing Microsoft Outlook emails via Microsoft Graph" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "click>=8.1", + "ess-dirs", + "msal>=1.28.0", + "python-dotenv>=1.0.0", + "requests>=2.31.0", +] + +[project.scripts] +ess-outlook = "ess_outlook.__main__:main" + +[tool.uv.sources] +ess-dirs = { workspace = true } + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_outlook"] +exclude = ["**/test_*.py"] diff --git a/packages/python/ess-outlook/src/ess_outlook/__init__.py b/packages/python/ess-outlook/src/ess_outlook/__init__.py new file mode 100644 index 0000000..b0f4a5f --- /dev/null +++ b/packages/python/ess-outlook/src/ess_outlook/__init__.py @@ -0,0 +1,3 @@ +"""Outlook CLI -- read and manage Microsoft Outlook emails via Microsoft Graph.""" + +__version__ = "0.1.0" diff --git a/packages/python/ess-outlook/src/ess_outlook/__main__.py b/packages/python/ess-outlook/src/ess_outlook/__main__.py new file mode 100644 index 0000000..bdb0eb6 --- /dev/null +++ b/packages/python/ess-outlook/src/ess_outlook/__main__.py @@ -0,0 +1,15 @@ +"""Entry point for ``python -m ess_outlook``.""" + +from dotenv import load_dotenv + +from ess_outlook.cli import cli + +load_dotenv() + + +def main() -> None: + cli() # pylint: disable=no-value-for-parameter + + +if __name__ == "__main__": + main() diff --git a/packages/python/ess-outlook/src/ess_outlook/cli.py b/packages/python/ess-outlook/src/ess_outlook/cli.py new file mode 100644 index 0000000..cc113a2 --- /dev/null +++ b/packages/python/ess-outlook/src/ess_outlook/cli.py @@ -0,0 +1,144 @@ +"""Click CLI for Outlook email management.""" + +from __future__ import annotations + +import json + +import click + +from ess_outlook.graph import GraphClient + + +def _handle_error(exc: Exception) -> None: + """Convert any backend exception into a ClickException.""" + raise click.ClickException(str(exc)) from exc + + +@click.group() +@click.option("--json-output", "json_output", is_flag=True, help="Emit JSON output.") +@click.pass_context +def cli(ctx: click.Context, json_output: bool) -> None: + """Read and manage Microsoft Outlook emails from the command line.""" + ctx.ensure_object(dict) + ctx.obj["json_output"] = json_output + ctx.obj["client"] = GraphClient() + + +@cli.command("list") +@click.option("--limit", default=25, show_default=True, help="Max messages to show.") +@click.option("--unread-only", is_flag=True, help="Only show unread messages.") +@click.option("--folder", default="inbox", show_default=True, help="Mail folder name.") +@click.pass_context +def list_messages( + ctx: click.Context, + limit: int, + unread_only: bool, + folder: str, +) -> None: + """List recent inbox messages.""" + client = ctx.obj["client"] + try: + messages = client.list_messages( + folder=folder, limit=limit, unread_only=unread_only + ) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(messages, indent=2, default=str)) + return + + if not messages: + click.echo("No messages found.") + return + + click.echo(f"{'ID':<10} {'Status':<8} {'Sender':<30} {'Date':<22} {'Subject'}") + click.echo("-" * 100) + + for msg in messages: + status = " " if msg["is_read"] else "*" + sender = msg["sender_name"] or msg["sender_address"] + if len(sender) > 28: # noqa: PLR2004 -- column width for table display + sender = sender[:25] + "..." + subject = msg["subject"] + if len(subject) > 40: # noqa: PLR2004 -- column width for table display + subject = subject[:37] + "..." + + # Truncate long IDs (Graph API IDs are very long opaque strings) + display_id = str(msg["id"]) + if len(display_id) > 8: # noqa: PLR2004 -- column width for table display + display_id = display_id[:8] + ".." + + display_date = str(msg["date"]) + click.echo( + f"{display_id:<10} {status:<8} {sender:<30} {display_date:<22} {subject}" + ) + + +@cli.command() +@click.argument("message_id") +@click.pass_context +def read(ctx: click.Context, message_id: str) -> None: + """Read a specific message by ID.""" + client = ctx.obj["client"] + try: + msg = client.get_message(message_id) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(msg, indent=2, default=str)) + return + + click.echo(f"From: {msg['sender_name']} <{msg['sender_address']}>") + click.echo(f"Date: {msg['date']}") + click.echo(f"Subject: {msg['subject']}") + click.echo(f"Status: {'Read' if msg['is_read'] else 'Unread'}") + click.echo("-" * 60) + click.echo(msg.get("body", "")) + + +@cli.command("mark-read") +@click.argument("message_id", required=False) +@click.option("--all", "mark_all", is_flag=True, help="Mark all messages as read.") +@click.option("--folder", default="inbox", show_default=True, help="Mail folder name.") +@click.pass_context +def mark_read( + ctx: click.Context, + message_id: str | None, + mark_all: bool, + folder: str, +) -> None: + """Mark a message (or all messages) as read.""" + client = ctx.obj["client"] + + if not message_id and not mark_all: + raise click.UsageError("Provide a MESSAGE_ID or use --all.") + + try: + if mark_all: + count = client.mark_all_as_read(folder=folder) + click.echo(f"Marked {count} message(s) as read.") + else: + client.mark_as_read(message_id) + click.echo(f"Message {message_id} marked as read.") + except Exception as exc: + _handle_error(exc) + + +@cli.command() +@click.argument("message_id") +@click.option("--force", is_flag=True, help="Skip confirmation prompt.") +@click.pass_context +def delete(ctx: click.Context, message_id: str, force: bool) -> None: + """Delete a message by ID.""" + client = ctx.obj["client"] + + if not force: + click.confirm(f"Delete message {message_id}? This cannot be undone", abort=True) + + try: + client.delete_message(message_id) + click.echo(f"Message {message_id} deleted.") + except Exception as exc: + _handle_error(exc) diff --git a/packages/python/ess-outlook/src/ess_outlook/graph.py b/packages/python/ess-outlook/src/ess_outlook/graph.py new file mode 100644 index 0000000..b57651b --- /dev/null +++ b/packages/python/ess-outlook/src/ess_outlook/graph.py @@ -0,0 +1,206 @@ +"""Microsoft Graph API client for Outlook email.""" + +from __future__ import annotations + +import json +import logging +import os +import webbrowser +from datetime import datetime, timezone +from http import HTTPStatus + +import msal +import requests +from ess_dirs import write_secure + +GRAPH_BASE = "https://graph.microsoft.com/v1.0/me" +TOKEN_CACHE = os.path.expanduser("~/.cache/ess-outlook/graph_token_cache.json") +SCOPES = ["Mail.ReadWrite"] + +logger = logging.getLogger(__name__) + + +class GraphAuthError(Exception): + """Raised when Graph API authentication fails.""" + + +def _parse_date(value: str) -> datetime: + """Parse an ISO-8601 date string into a timezone-aware datetime.""" + if not value: + return datetime.min.replace(tzinfo=timezone.utc) + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _get_app() -> msal.PublicClientApplication: + """Build an MSAL app using env vars for client/tenant.""" + client_id = os.environ.get("CLIENT_ID") + tenant_id = os.environ.get("TENANT_ID") + if not client_id or not tenant_id: + msg = "CLIENT_ID and TENANT_ID must be set in environment or .env" + raise GraphAuthError(msg) + + authority = f"https://login.microsoftonline.com/{tenant_id}" + cache = msal.SerializableTokenCache() + if os.path.exists(TOKEN_CACHE): + with open(TOKEN_CACHE) as f: + cache.deserialize(f.read()) + + return msal.PublicClientApplication( + client_id, authority=authority, token_cache=cache + ) + + +def _save_cache(app: msal.PublicClientApplication) -> None: + """Persist the MSAL token cache to disk with restrictive permissions.""" + cache = app.token_cache + if cache.has_state_changed: + write_secure(TOKEN_CACHE, cache.serialize()) + + +def get_token() -> str: + """Acquire a Graph API access token (silent refresh or device-code flow).""" + app = _get_app() + + # 1. Try silent refresh from cached account + accounts = app.get_accounts() + if accounts: + result = app.acquire_token_silent(SCOPES, account=accounts[0]) + if result and "access_token" in result: + _save_cache(app) + return result["access_token"] + + # 2. Device-code flow -- open browser automatically + flow = app.initiate_device_flow(scopes=SCOPES) + if "user_code" not in flow: + msg = flow.get("error_description", "Failed to initiate device flow") + raise GraphAuthError(msg) + + code = flow["user_code"] + logger.warning( + "\n Visit: https://microsoft.com/devicelogin\n Code: %s\n", + code, + ) + webbrowser.open("https://microsoft.com/devicelogin") + result = app.acquire_token_by_device_flow(flow) + + if "access_token" in result: + _save_cache(app) + return result["access_token"] + + # 3. Auth failed -- give actionable error + error = result.get("error_description", "Authentication failed") + msg = ( + f"Authentication failed: {error}\n" + "If blocked by Conditional Access, try from your terminal." + ) + raise GraphAuthError(msg) + + +def _handle_response(resp: requests.Response) -> dict: + """Raise on HTTP errors, return parsed JSON.""" + if resp.status_code == HTTPStatus.UNAUTHORIZED: + msg = "Token expired or invalid. Re-run to authenticate." + raise GraphAuthError(msg) + resp.raise_for_status() + return resp.json() + + +class GraphClient: + """Read and manage Outlook emails via Microsoft Graph API.""" + + def __init__(self, access_token: str | None = None) -> None: + self._access_token = access_token + + def _headers(self) -> dict[str, str]: + token = self._access_token or get_token() + return {"Authorization": f"Bearer {token}"} + + def list_messages( + self, + *, + folder: str = "inbox", + limit: int = 25, + unread_only: bool = False, + ) -> list[dict]: + """List recent messages, newest first.""" + url = f"{GRAPH_BASE}/mailFolders/{folder}/messages" + params: dict[str, str | int] = { + "$top": limit, + "$orderby": "receivedDateTime desc", + "$select": "id,subject,from,receivedDateTime,isRead", + } + if unread_only: + params["$filter"] = "isRead eq false" + + resp = requests.get(url, headers=self._headers(), params=params, timeout=30) + data = _handle_response(resp) + + return [ + { + "id": msg["id"], + "subject": msg.get("subject", ""), + "sender_name": msg.get("from", {}) + .get("emailAddress", {}) + .get("name", ""), + "sender_address": msg.get("from", {}) + .get("emailAddress", {}) + .get("address", ""), + "date": _parse_date(msg.get("receivedDateTime", "")), + "is_read": msg.get("isRead", True), + } + for msg in data.get("value", []) + ] + + def get_message(self, message_id: str) -> dict: + """Get a single message with full body content.""" + url = f"{GRAPH_BASE}/messages/{message_id}" + params = {"$select": "id,subject,from,receivedDateTime,isRead,body"} + resp = requests.get(url, headers=self._headers(), params=params, timeout=30) + data = _handle_response(resp) + + return { + "id": data["id"], + "subject": data.get("subject", ""), + "sender_name": data.get("from", {}).get("emailAddress", {}).get("name", ""), + "sender_address": data.get("from", {}) + .get("emailAddress", {}) + .get("address", ""), + "date": _parse_date(data.get("receivedDateTime", "")), + "is_read": data.get("isRead", True), + "body": data.get("body", {}).get("content", ""), + } + + def mark_as_read(self, message_id: str) -> bool: + """Mark a single message as read.""" + url = f"{GRAPH_BASE}/messages/{message_id}" + resp = requests.patch( + url, + headers={**self._headers(), "Content-Type": "application/json"}, + data=json.dumps({"isRead": True}), + timeout=30, + ) + _handle_response(resp) + return True + + def mark_all_as_read(self, *, folder: str = "inbox") -> int: + """Mark all unread messages in a folder as read (paginates until empty).""" + total_marked = 0 + while True: + unread = self.list_messages(folder=folder, limit=100, unread_only=True) + if not unread: + return total_marked + for msg in unread: + self.mark_as_read(msg["id"]) + total_marked += len(unread) + + def delete_message(self, message_id: str) -> bool: + """Delete a single message.""" + url = f"{GRAPH_BASE}/messages/{message_id}" + resp = requests.delete(url, headers=self._headers(), timeout=30) + if resp.status_code == HTTPStatus.NO_CONTENT: + return True + _handle_response(resp) + return True diff --git a/packages/python/ess-webex/.env.example b/packages/python/ess-webex/.env.example new file mode 100644 index 0000000..d50ff25 --- /dev/null +++ b/packages/python/ess-webex/.env.example @@ -0,0 +1,6 @@ +# OAuth integration (from https://developer.webex.com/my-apps) +WEBEX_CLIENT_ID= +WEBEX_CLIENT_SECRET= + +# Or use a personal access token directly (12-hour TTL) +# WEBEX_ACCESS_TOKEN= diff --git a/packages/python/ess-webex/README.md b/packages/python/ess-webex/README.md new file mode 100644 index 0000000..3763236 --- /dev/null +++ b/packages/python/ess-webex/README.md @@ -0,0 +1,88 @@ +# ess-webex + +Interact with Webex rooms, messages, teams, and meetings from the command line. + +```bash +ess-webex rooms --max 5 +``` + +``` +Title Type Last Activity ID +---------------------------------------------------------------------------------------------------- +General group 2026-04-10T10:00:00Z Y2lzY29zcGF... +``` + +## Installation + +In a `uv` workspace, declare the dependency in your `pyproject.toml`: + +```toml +[project] +dependencies = ["ess-webex"] + +[tool.uv.sources] +ess-webex = { workspace = true } +``` + +Then run `uv sync --all-packages` from the workspace root. + +## Auth + +### OAuth integration (recommended) + +1. Create an integration at https://developer.webex.com/my-apps/new/integration + - Redirect URI: `http://localhost:3030/callback` + - Scopes: `spark:rooms_read`, `spark:messages_read`, `spark:messages_write`, `meeting:schedules_read`, `meeting:schedules_write`, `spark:people_read` +2. Add your Client ID and Secret to `.env`: + ``` + WEBEX_CLIENT_ID= + WEBEX_CLIENT_SECRET= + ``` +3. Run `ess-webex login` -- opens a browser to authorize, then caches a refresh token (~90 day TTL, auto-renews on use). + +### Personal access token (quick start) + +Set `WEBEX_ACCESS_TOKEN` in `.env` for a 12-hour personal token: + +``` +WEBEX_ACCESS_TOKEN= +``` + +Get one at https://developer.webex.com/docs/getting-your-personal-access-token + +## Usage + +```bash +# Login (one-time OAuth flow) +ess-webex login + +# Rooms +ess-webex rooms # List rooms (sorted by last activity) +ess-webex rooms --type group --max 10 # Only group rooms +ess-webex rooms --team # Rooms in a team +ess-webex rooms # Room details + +# Teams +ess-webex teams # List teams +ess-webex teams # Team details + +# Messages +ess-webex messages list # Recent messages in a room +ess-webex messages read # Read a specific message +ess-webex messages send "Hello" --room # Send to a room +ess-webex messages send "Hi" --email user@example.com # Direct message + +# Meetings +ess-webex meetings list # List meetings +ess-webex meetings list --from 2026-04-01 # Filter by date +ess-webex meetings get # Meeting details +ess-webex meetings create "Standup" --start 2026-04-11T09:00:00 --end 2026-04-11T09:30:00 +``` + +Add `--json-output` to any command for JSON output. + +## Running Tests + +```bash +uv run pytest packages/python/ess-webex/ +``` diff --git a/packages/python/ess-webex/pyproject.toml b/packages/python/ess-webex/pyproject.toml new file mode 100644 index 0000000..de449f4 --- /dev/null +++ b/packages/python/ess-webex/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "ess-webex" +version = "0.1.0" +description = "CLI tool for interacting with Webex rooms, messages, teams, and meetings" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "click>=8.1", + "ess-dirs", + "python-dotenv>=1.0.0", + "wxc-sdk>=1.32.0", +] + +[project.scripts] +ess-webex = "ess_webex.__main__:main" + +[tool.uv.sources] +ess-dirs = { workspace = true } + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_webex"] +exclude = ["**/test_*.py"] diff --git a/packages/python/ess-webex/src/ess_webex/__init__.py b/packages/python/ess-webex/src/ess_webex/__init__.py new file mode 100644 index 0000000..a4f704a --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/__init__.py @@ -0,0 +1,3 @@ +"""Webex CLI -- interact with Webex rooms, messages, teams, and meetings.""" + +__version__ = "0.1.0" diff --git a/packages/python/ess-webex/src/ess_webex/__main__.py b/packages/python/ess-webex/src/ess_webex/__main__.py new file mode 100644 index 0000000..2c69d66 --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/__main__.py @@ -0,0 +1,15 @@ +"""Entry point for ``python -m ess_webex``.""" + +from dotenv import load_dotenv + +from ess_webex.cli import cli + +load_dotenv() + + +def main() -> None: + cli() # pylint: disable=no-value-for-parameter + + +if __name__ == "__main__": + main() diff --git a/packages/python/ess-webex/src/ess_webex/auth.py b/packages/python/ess-webex/src/ess_webex/auth.py new file mode 100644 index 0000000..4918bd1 --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/auth.py @@ -0,0 +1,199 @@ +"""OAuth token management for the ess-webex CLI. + +Handles the authorization code flow, token caching, and automatic refresh. +Tokens are cached at ~/.cache/ess-webex/token.json. +""" + +from __future__ import annotations + +import json +import os +import secrets +import sys +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlencode, urlparse + +import requests +from ess_dirs import write_secure + +TOKEN_CACHE = os.path.expanduser("~/.cache/ess-webex/token.json") +AUTHORIZE_URL = "https://webexapis.com/v1/authorize" +TOKEN_URL = "https://webexapis.com/v1/access_token" +REDIRECT_URI = "http://localhost:3030/callback" +REFRESH_MARGIN_SECS = 120 + +SCOPES = " ".join( + [ + "spark:rooms_read", + "spark:messages_read", + "spark:messages_write", + "meeting:schedules_read", + "meeting:schedules_write", + "spark:people_read", + ] +) + + +class WebexOAuthError(Exception): + """Raised when OAuth authentication fails.""" + + +def _load_cache() -> dict | None: + """Load cached token data from disk.""" + if not os.path.exists(TOKEN_CACHE): + return None + with open(TOKEN_CACHE) as f: + return json.load(f) + + +def _save_cache(data: dict) -> None: + """Persist token data to disk with restrictive permissions.""" + write_secure(TOKEN_CACHE, json.dumps(data, indent=2)) + + +def _is_expired(data: dict) -> bool: + """Check if the cached access token is expired or about to expire.""" + expires_at = data.get("expires_at", 0) + return time.time() > (expires_at - REFRESH_MARGIN_SECS) + + +def _refresh_token(data: dict) -> dict: + """Use the refresh token to get a new access token.""" + client_id = os.environ.get("WEBEX_CLIENT_ID", "") + client_secret = os.environ.get("WEBEX_CLIENT_SECRET", "") + if not client_id or not client_secret: + msg = "WEBEX_CLIENT_ID and WEBEX_CLIENT_SECRET required for refresh" + raise WebexOAuthError(msg) + + resp = requests.post( + TOKEN_URL, + data={ + "grant_type": "refresh_token", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": data["refresh_token"], + }, + timeout=30, + ) + if resp.status_code != 200: # noqa: PLR2004 + msg = f"Token refresh failed: {resp.text}" + raise WebexOAuthError(msg) + + token_data = resp.json() + token_data["expires_at"] = time.time() + token_data.get("expires_in", 43200) + _save_cache(token_data) + return token_data + + +def get_access_token() -> str: + """Get a valid access token, refreshing if needed. + + Priority: + 1. WEBEX_ACCESS_TOKEN env var (manual/personal token) + 2. Cached OAuth token (auto-refreshed) + """ + env_token = os.environ.get("WEBEX_ACCESS_TOKEN") + if env_token: + return env_token + + cached = _load_cache() + if cached is None: + msg = ( + "No Webex token found. Run 'ess-webex login' to authenticate, " + "or set WEBEX_ACCESS_TOKEN in your environment." + ) + raise WebexOAuthError(msg) + + if _is_expired(cached): + cached = _refresh_token(cached) + + return cached["access_token"] + + +def login() -> str: + """Run the OAuth authorization code flow. + + Opens a browser for the user to authorize, captures the callback + on a local HTTP server, exchanges the code for tokens. + """ + client_id = os.environ.get("WEBEX_CLIENT_ID", "") + client_secret = os.environ.get("WEBEX_CLIENT_SECRET", "") + if not client_id or not client_secret: + msg = "Set WEBEX_CLIENT_ID and WEBEX_CLIENT_SECRET in .env" + raise WebexOAuthError(msg) + + # Random state token to prevent OAuth CSRF attacks. + state = secrets.token_urlsafe(32) + + params = urlencode( + { + "client_id": client_id, + "response_type": "code", + "redirect_uri": REDIRECT_URI, + "scope": SCOPES, + "state": state, + } + ) + auth_url = f"{AUTHORIZE_URL}?{params}" + + auth_code = None + state_valid = False + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 -- name required by BaseHTTPRequestHandler + nonlocal auth_code, state_valid + query = parse_qs(urlparse(self.path).query) + returned_state = query.get("state", [None])[0] + state_valid = returned_state == state + auth_code = query.get("code", [None])[0] + self.send_response(200) # noqa: PLR2004 + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"

Authenticated! You can close this tab.

") + + def log_message(self, format, *args): # noqa: A002 + pass + + print( # noqa: T201 + "Opening browser for Webex authorization...", + file=sys.stderr, + ) + webbrowser.open(auth_url) + + server = HTTPServer(("localhost", 3030), CallbackHandler) # noqa: S104 + try: + server.handle_request() + finally: + server.server_close() + + if not auth_code: + msg = "No authorization code received" + raise WebexOAuthError(msg) + + if not state_valid: + msg = "OAuth state mismatch -- possible CSRF attack" + raise WebexOAuthError(msg) + + resp = requests.post( + TOKEN_URL, + data={ + "grant_type": "authorization_code", + "client_id": client_id, + "client_secret": client_secret, + "code": auth_code, + "redirect_uri": REDIRECT_URI, + }, + timeout=30, + ) + if resp.status_code != 200: # noqa: PLR2004 + msg = f"Token exchange failed: {resp.text}" + raise WebexOAuthError(msg) + + token_data = resp.json() + token_data["expires_at"] = time.time() + token_data.get("expires_in", 43200) + _save_cache(token_data) + + print("Authenticated successfully.", file=sys.stderr) # noqa: T201 + return token_data["access_token"] diff --git a/packages/python/ess-webex/src/ess_webex/cli.py b/packages/python/ess-webex/src/ess_webex/cli.py new file mode 100644 index 0000000..7eebfdd --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/cli.py @@ -0,0 +1,356 @@ +"""Click CLI for Webex.""" + +from __future__ import annotations + +import json + +import click + +from ess_webex.auth import WebexOAuthError, get_access_token, login +from ess_webex.client import WebexClient + + +def _get_client(ctx: click.Context) -> WebexClient: + """Lazily create and cache the WebexClient on the context.""" + if "client" not in ctx.obj: + try: + token = get_access_token() + except WebexOAuthError as exc: + raise click.ClickException(str(exc)) from exc + ctx.obj["client"] = WebexClient(access_token=token) + return ctx.obj["client"] + + +def _handle_error(exc: Exception) -> None: + """Convert any backend exception into a ClickException.""" + raise click.ClickException(str(exc)) from exc + + +def _echo_table(rows: list[dict], columns: list[tuple[str, str, int]]) -> None: + """Print a formatted table from a list of dicts. + + *columns* is a list of (dict_key, header_label, width) tuples. + """ + header = "".join(f"{label:<{width}}" for _, label, width in columns) + click.echo(header) + click.echo("-" * len(header)) + for row in rows: + parts = [] + for key, _, width in columns: + val = str(row.get(key) or "") + if len(val) > width - 2: + val = val[: width - 5] + "..." + parts.append(f"{val:<{width}}") + click.echo("".join(parts)) + + +def _echo_detail(data: dict) -> None: + """Print key-value pairs for a single item.""" + max_key = max(len(k) for k in data) + for key, val in data.items(): + click.echo(f"{key:<{max_key + 2}} {val}") + + +# -- Main group -- + + +@click.group() +@click.option("--json-output", "json_output", is_flag=True, help="Emit JSON output.") +@click.pass_context +def cli(ctx: click.Context, json_output: bool) -> None: + """Interact with Webex rooms, messages, teams, and meetings.""" + ctx.ensure_object(dict) + ctx.obj["json_output"] = json_output + + +# -- Login -- + + +@cli.command("login") +def login_cmd() -> None: + """Authenticate with Webex via OAuth (opens browser).""" + try: + login() + click.echo("Login successful. Token cached.") + except WebexOAuthError as exc: + raise click.ClickException(str(exc)) from exc + + +# -- Rooms -- + + +@cli.command("rooms") +@click.argument("room_id", required=False) +@click.option("--max", "max_results", default=25, show_default=True, help="Max results") +@click.option( + "--type", + "type_", + type=click.Choice(["group", "direct"]), + help="Room type.", +) +@click.option("--team", "team_id", help="Filter by team ID.") +@click.option( + "--sort-by", + type=click.Choice(["lastactivity", "created"]), + default="lastactivity", + show_default=True, +) +@click.pass_context +def rooms( # noqa: PLR0913 -- Click requires all params as function args + ctx: click.Context, + room_id: str | None, + max_results: int, + type_: str | None, + team_id: str | None, + sort_by: str, +) -> None: + """List rooms or get room details.""" + client = _get_client(ctx) + try: + if room_id: + data = client.get_room(room_id) + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + else: + _echo_detail(data) + return + + data = client.list_rooms( + max_results=max_results, type_=type_, team_id=team_id, sort_by=sort_by + ) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + return + + if not data: + click.echo("No rooms found.") + return + + _echo_table( + data, + [ + ("title", "Title", 40), + ("type", "Type", 10), + ("last_activity", "Last Activity", 28), + ("id", "ID", 50), + ], + ) + + +# -- Teams -- + + +@cli.command("teams") +@click.argument("team_id", required=False) +@click.option("--max", "max_results", default=25, show_default=True, help="Max results") +@click.pass_context +def teams(ctx: click.Context, team_id: str | None, max_results: int) -> None: + """List teams or get team details.""" + client = _get_client(ctx) + try: + if team_id: + data = client.get_team(team_id) + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + else: + _echo_detail(data) + return + + data = client.list_teams(max_results=max_results) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + return + + if not data: + click.echo("No teams found.") + return + + _echo_table( + data, + [ + ("name", "Name", 40), + ("created", "Created", 28), + ("id", "ID", 50), + ], + ) + + +# -- Messages -- + + +@cli.group("messages") +def messages() -> None: + """Read and send Webex messages.""" + + +@messages.command("list") +@click.argument("room_id") +@click.option("--max", "max_results", default=25, show_default=True, help="Max results") +@click.pass_context +def messages_list(ctx: click.Context, room_id: str, max_results: int) -> None: + """List recent messages in a room.""" + client = _get_client(ctx) + try: + data = client.list_messages(room_id, max_results=max_results) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + return + + if not data: + click.echo("No messages found.") + return + + _echo_table( + data, + [("person_email", "From", 30), ("created", "Date", 28), ("text", "Text", 60)], + ) + + +@messages.command("read") +@click.argument("message_id") +@click.pass_context +def messages_read(ctx: click.Context, message_id: str) -> None: + """Read a specific message.""" + client = _get_client(ctx) + try: + data = client.get_message(message_id) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + return + + click.echo(f"From: {data['person_email']}") + click.echo(f"Date: {data['created']}") + click.echo("-" * 60) + click.echo(data.get("text") or data.get("markdown") or "") + + +@messages.command("send") +@click.argument("text") +@click.option("--room", "room_id", help="Room ID to send to.") +@click.option("--email", "to_person_email", help="Email for direct message.") +@click.option("--markdown", is_flag=True, help="Treat text as markdown.") +@click.pass_context +def messages_send( + ctx: click.Context, + text: str, + room_id: str | None, + to_person_email: str | None, + markdown: bool, +) -> None: + """Send a message to a room or person.""" + if not room_id and not to_person_email: + raise click.UsageError("Provide --room or --email.") + + client = _get_client(ctx) + try: + data = client.send_message( + text, + room_id=room_id, + to_person_email=to_person_email, + markdown=text if markdown else None, + ) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + else: + click.echo(f"Message sent (id: {data['id']})") + + +# -- Meetings -- + + +@cli.group("meetings") +def meetings() -> None: + """List and create Webex meetings.""" + + +@meetings.command("list") +@click.option("--from", "from_", help="Start date (ISO-8601).") +@click.option("--to", "to_", help="End date (ISO-8601).") +@click.option("--max", "max_results", default=25, show_default=True, help="Max results") +@click.pass_context +def meetings_list( + ctx: click.Context, from_: str | None, to_: str | None, max_results: int +) -> None: + """List meetings in a date range.""" + client = _get_client(ctx) + try: + data = client.list_meetings(from_=from_, to_=to_, max_results=max_results) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + return + + if not data: + click.echo("No meetings found.") + return + + _echo_table( + data, + [ + ("title", "Title", 40), + ("start", "Start", 22), + ("end", "End", 22), + ("state", "State", 12), + ], + ) + + +@meetings.command("get") +@click.argument("meeting_id") +@click.pass_context +def meetings_get(ctx: click.Context, meeting_id: str) -> None: + """Get details of a meeting.""" + client = _get_client(ctx) + try: + data = client.get_meeting(meeting_id) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + else: + _echo_detail(data) + + +@meetings.command("create") +@click.argument("title") +@click.option("--start", required=True, help="Start datetime (ISO-8601).") +@click.option("--end", required=True, help="End datetime (ISO-8601).") +@click.option("--invitees", help="Comma-separated emails.") +@click.pass_context +def meetings_create( + ctx: click.Context, + title: str, + start: str, + end: str, + invitees: str | None, +) -> None: + """Create a meeting.""" + client = _get_client(ctx) + invitee_list = [e.strip() for e in invitees.split(",")] if invitees else None + try: + data = client.create_meeting(title, start, end, invitees=invitee_list) + except Exception as exc: + _handle_error(exc) + + if ctx.obj["json_output"]: + click.echo(json.dumps(data, indent=2, default=str)) + else: + click.echo(f"Meeting created: {data.get('title')} (id: {data['id']})") diff --git a/packages/python/ess-webex/src/ess_webex/client/__init__.py b/packages/python/ess-webex/src/ess_webex/client/__init__.py new file mode 100644 index 0000000..a6c4d93 --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/client/__init__.py @@ -0,0 +1,5 @@ +"""Webex client package.""" + +from .client import WebexAuthError, WebexClient + +__all__ = ["WebexAuthError", "WebexClient"] diff --git a/packages/python/ess-webex/src/ess_webex/client/client.py b/packages/python/ess-webex/src/ess_webex/client/client.py new file mode 100644 index 0000000..5261994 --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/client/client.py @@ -0,0 +1,170 @@ +"""Webex client wrapping wxc_sdk.WebexSimpleApi.""" + +from __future__ import annotations + +import os +from itertools import islice + +from wxc_sdk import WebexSimpleApi + + +class WebexAuthError(Exception): + """Raised when Webex authentication fails.""" + + +def _to_dict(obj: object, keys: list[str]) -> dict: + """Extract named attributes from an SDK object into a plain dict.""" + return {k: getattr(obj, k, None) for k in keys} + + +_ROOM_KEYS = [ + "id", + "title", + "team_id", + "type", + "last_activity", + "creator_id", + "created", + "is_locked", + "is_read_only", +] +_TEAM_KEYS = ["id", "name", "description", "created"] +_MESSAGE_KEYS = [ + "id", + "room_id", + "person_id", + "person_email", + "text", + "markdown", + "created", +] +_MEETING_KEYS = [ + "id", + "title", + "start", + "end", + "timezone", + "host_email", + "meeting_type", + "state", +] + + +class WebexClient: + """High-level interface to Webex via wxc-sdk.""" + + def __init__(self, access_token: str | None = None) -> None: + token = access_token or os.environ.get("WEBEX_ACCESS_TOKEN") + if not token: + msg = ( + "No Webex access token. Set WEBEX_ACCESS_TOKEN in " + "your environment or .env file." + ) + raise WebexAuthError(msg) + self._api = WebexSimpleApi(tokens=token) + + # -- Rooms -- + + def list_rooms( + self, + *, + max_results: int = 25, + type_: str | None = None, + team_id: str | None = None, + sort_by: str | None = "lastactivity", + ) -> list[dict]: + """List rooms/spaces the authenticated user belongs to.""" + kwargs: dict = {} + if type_: + kwargs["type_"] = type_ + if team_id: + kwargs["team_id"] = team_id + if sort_by: + kwargs["sort_by"] = sort_by + rooms = islice(self._api.rooms.list(**kwargs), max_results) + return [_to_dict(r, _ROOM_KEYS) for r in rooms] + + def get_room(self, room_id: str) -> dict: + """Get details of a single room.""" + return _to_dict(self._api.rooms.details(room_id), _ROOM_KEYS) + + # -- Teams -- + + def list_teams(self, *, max_results: int = 25) -> list[dict]: + """List teams the authenticated user belongs to.""" + teams = islice(self._api.teams.list(), max_results) + return [_to_dict(t, _TEAM_KEYS) for t in teams] + + def get_team(self, team_id: str) -> dict: + """Get details of a single team.""" + return _to_dict(self._api.teams.details(team_id), _TEAM_KEYS) + + # -- Messages -- + + def list_messages(self, room_id: str, *, max_results: int = 25) -> list[dict]: + """List recent messages in a room, newest first.""" + msgs = islice(self._api.messages.list(room_id=room_id), max_results) + return [_to_dict(m, _MESSAGE_KEYS) for m in msgs] + + def get_message(self, message_id: str) -> dict: + """Get a single message by ID.""" + return _to_dict(self._api.messages.details(message_id), _MESSAGE_KEYS) + + def send_message( + self, + text: str, + *, + room_id: str | None = None, + to_person_email: str | None = None, + markdown: str | None = None, + ) -> dict: + """Send a message to a room or direct to a person.""" + if not room_id and not to_person_email: + msg = "Provide room_id or to_person_email" + raise ValueError(msg) + kwargs: dict = {"text": text} + if room_id: + kwargs["room_id"] = room_id + if to_person_email: + kwargs["to_person_email"] = to_person_email + if markdown: + kwargs["markdown"] = markdown + msg = self._api.messages.create(**kwargs) + return _to_dict(msg, _MESSAGE_KEYS) + + # -- Meetings -- + + def list_meetings( + self, + *, + from_: str | None = None, + to_: str | None = None, + max_results: int = 25, + ) -> list[dict]: + """List meetings in a date range.""" + kwargs: dict = {} + if from_: + kwargs["from_"] = from_ + if to_: + kwargs["to_"] = to_ + meetings = islice(self._api.meetings.list(**kwargs), max_results) + return [_to_dict(m, _MEETING_KEYS) for m in meetings] + + def get_meeting(self, meeting_id: str) -> dict: + """Get details of a single meeting.""" + return _to_dict(self._api.meetings.get(meeting_id), _MEETING_KEYS) + + def create_meeting( + self, + title: str, + start: str, + end: str, + *, + invitees: list[str] | None = None, + ) -> dict: + """Create a meeting.""" + kwargs: dict = {"title": title, "start": start, "end": end} + if invitees: + kwargs["invitees"] = [{"email": e} for e in invitees] + meeting = self._api.meetings.create(**kwargs) + return _to_dict(meeting, _MEETING_KEYS) diff --git a/packages/python/ess-webex/src/ess_webex/client/test_client.py b/packages/python/ess-webex/src/ess_webex/client/test_client.py new file mode 100644 index 0000000..5fff1b8 --- /dev/null +++ b/packages/python/ess-webex/src/ess_webex/client/test_client.py @@ -0,0 +1,136 @@ +# ruff: noqa: PLR2004 -- magic numbers in test assertions are expected literals +"""Tests for WebexClient -- mocks wxc_sdk to avoid needing a token.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from ess_webex.client import WebexAuthError, WebexClient + + +def _make_client() -> WebexClient: + """Create a WebexClient with a fake token.""" + return WebexClient(access_token="fake-token") # noqa: S106 -- test fixture token + + +def _room(**kwargs) -> SimpleNamespace: + defaults = { + "id": "room-1", + "title": "General", + "team_id": "team-1", + "type": "group", + "last_activity": "2026-04-10T10:00:00Z", + "creator_id": "user-1", + "created": "2026-01-01T00:00:00Z", + "is_locked": False, + "is_read_only": False, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def _team(**kwargs) -> SimpleNamespace: + defaults = { + "id": "team-1", + "name": "Engineering", + "description": "Engineering team", + "created": "2026-01-01T00:00:00Z", + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def _message(**kwargs) -> SimpleNamespace: + defaults = { + "id": "msg-1", + "room_id": "room-1", + "person_id": "user-1", + "person_email": "alice@example.com", + "text": "Hello world", + "markdown": None, + "created": "2026-04-10T10:00:00Z", + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +class TestListRooms: + def test_returns_dicts(self): + client = _make_client() + rooms = iter([_room(), _room(id="room-2", title="Random")]) + client._api.rooms.list = MagicMock(return_value=rooms) + result = client.list_rooms() + + assert len(result) == 2 + assert result[0]["id"] == "room-1" + assert result[0]["title"] == "General" + assert result[1]["id"] == "room-2" + + def test_filters_by_team(self): + client = _make_client() + client._api.rooms.list = MagicMock(return_value=iter([_room()])) + client.list_rooms(team_id="team-1") + + client._api.rooms.list.assert_called_once_with( + team_id="team-1", sort_by="lastactivity" + ) + + +class TestListTeams: + def test_returns_dicts(self): + client = _make_client() + client._api.teams.list = MagicMock(return_value=iter([_team()])) + result = client.list_teams() + + assert len(result) == 1 + assert result[0]["name"] == "Engineering" + + +class TestMessages: + def test_list_messages(self): + client = _make_client() + client._api.messages.list = MagicMock(return_value=iter([_message()])) + result = client.list_messages("room-1") + + assert len(result) == 1 + assert result[0]["text"] == "Hello world" + + def test_send_message(self): + client = _make_client() + client._api.messages.create = MagicMock(return_value=_message(id="msg-new")) + result = client.send_message("Hi", room_id="room-1") + + assert result["id"] == "msg-new" + client._api.messages.create.assert_called_once_with(text="Hi", room_id="room-1") + + +class TestMeetings: + def test_list_meetings(self): + meeting = SimpleNamespace( + id="mtg-1", + title="Standup", + start="2026-04-10T09:00:00Z", + end="2026-04-10T09:30:00Z", + timezone="UTC", + host_email="alice@example.com", + meeting_type="scheduledMeeting", + state="active", + ) + client = _make_client() + client._api.meetings.list = MagicMock(return_value=iter([meeting])) + result = client.list_meetings() + + assert len(result) == 1 + assert result[0]["title"] == "Standup" + + +class TestAuth: + def test_missing_token_raises(self): + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(WebexAuthError), + ): + WebexClient() diff --git a/pyproject.toml b/pyproject.toml index 1ef9410..e4a0194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,10 +24,14 @@ members = [ "tools/python/pulumi-utils", "packages/python/ess-auth", "packages/python/ess-browser", + "packages/python/ess-dirs", + "packages/python/ess-outlook", "packages/python/ess-service-now-incident", + "packages/python/ess-webex", "packages/python/langsmith-client", "packages/python/langsmith-network", "packages/python/azure-ai", + "examples/python/ess-messages", ] [tool.ruff] diff --git a/tools/typescript/essentials-sync/src/jargon-list.ts b/tools/typescript/essentials-sync/src/jargon-list.ts index 5350a08..0fc6e09 100644 --- a/tools/typescript/essentials-sync/src/jargon-list.ts +++ b/tools/typescript/essentials-sync/src/jargon-list.ts @@ -21,11 +21,6 @@ export const DEFAULT_JARGON_PATTERNS: readonly JargonPattern[] = [ regex: hostnameSuffix("cisco.com"), message: "Contains an internal Cisco hostname.", }, - { - term: "*.webex.com", - regex: hostnameSuffix("webex.com"), - message: "Contains an internal Webex hostname.", - }, { term: "myid", regex: wordBoundary("myid"), @@ -36,11 +31,6 @@ export const DEFAULT_JARGON_PATTERNS: readonly JargonPattern[] = [ regex: wordBoundary("cec"), message: "References 'cec' (Cisco employee credential).", }, - { - term: "webex", - regex: wordBoundary("webex"), - message: "References Webex product naming.", - }, { term: "cisco-ceto", regex: wordBoundary("cisco-ceto"), diff --git a/tools/typescript/essentials-sync/src/prompts.ts b/tools/typescript/essentials-sync/src/prompts.ts index b7351c9..62e52eb 100644 --- a/tools/typescript/essentials-sync/src/prompts.ts +++ b/tools/typescript/essentials-sync/src/prompts.ts @@ -6,8 +6,9 @@ export const SYNC_SYSTEM_PROMPT = `You are the essentials-sync primary agent. Yo HARD RULES that must hold for every file you write into the target: 1. No secrets of any kind (API keys, tokens, passwords, certificates, private keys). 2. No personally identifiable information (employee names, employee IDs, emails, phone numbers). -3. No internal hostnames or URLs (anything under company-owned domains like cisco.com, webex.com, or other internal infra). -4. No company-specific identifiers or jargon left verbatim. Examples: 'cisco', 'webex', 'myid', 'cec', internal product codenames, internal org names. These must be either removed or generalized. +3. No internal hostnames or URLs (anything under company-owned infra domains like cisco.com, customer-specific tenants like '.webex.com', or similar non-public hosts). +4. No company-internal identifiers or jargon left verbatim. Examples: 'cisco', 'myid', 'cec', internal product codenames, internal org names. These must be either removed or generalized. Public third-party product names that the package legitimately wraps (e.g. 'outlook', 'webex', 'servicenow') are NOT jargon and may appear -- they describe what the package is. +5. Public third-party documentation and developer-portal URLs that the source uses to point readers at upstream docs (e.g. 'https://developer.webex.com/...', 'https://learn.microsoft.com/...', 'https://docs.servicenow.com/...') MUST be kept verbatim as clickable links. Do not paraphrase them into prose like "the Webex developer portal" -- the URL itself is the useful artifact for an open-source reader. NAMING CONVENTION: - When you create or rename a package destined for the essentials repo, the package name must begin with the prefix 'ess-'. For example, an internal package 'cisco-auth' should become 'ess-auth'. @@ -35,10 +36,11 @@ Replicate that shape. HARD RULES for the EXTRACTED package (the new 'packages/python/ess-/'): 1. No secrets, no PII. -2. No internal hostnames, URLs, or account IDs. -3. No company-specific identifiers or jargon left verbatim ('cisco', 'webex', 'myid', 'cec', internal product codenames, internal org names). -4. No company-specific defaults baked into function signatures, CLI flags, or constants. Defaults that used to be hard-coded must become required parameters, env-var-driven, or removed entirely. -5. Every file written under 'packages/python/ess-/' will be scanned. Any leftover company data here is a failure. +2. No internal hostnames, URLs, or account IDs (e.g. customer-specific '.webex.com' subdomains, '*.cisco.com' infra hosts). +3. No company-internal identifiers or jargon left verbatim ('cisco', 'myid', 'cec', internal product codenames, internal org names). Public third-party product names that the package wraps (e.g. 'outlook', 'webex', 'servicenow') are NOT jargon -- keep them when they describe what the package is. +4. Public third-party documentation and developer-portal URLs (e.g. 'https://developer.webex.com/...', 'https://learn.microsoft.com/...', 'https://docs.servicenow.com/...') must be kept verbatim as clickable links. Do not paraphrase them into prose -- the URL itself is the useful artifact for an open-source reader. +5. No company-specific defaults baked into function signatures, CLI flags, or constants. Defaults that used to be hard-coded must become required parameters, env-var-driven, or removed entirely. +6. Every file written under 'packages/python/ess-/' will be scanned. Any leftover company data here is a failure. PERMISSIONS for the WRAPPER (the rewritten 'tools/python//'): - The wrapper is allowed -- expected, even -- to contain the company-specific values that used to live in the source tool. Hostnames, default account names, internal SSO domains, team-specific AWS profiles, etc. belong here. @@ -244,6 +246,8 @@ Read every file under that path. Flag anything that would embarrass the team or DO NOT flag the following -- they are public, open-source conventions, not internal jargon: - Package names that start with the 'ess-' prefix (e.g. 'ess-browser', 'ess-auth', 'ess-service-now-incident'). 'ess' is short for 'essentials', the public open-source repo this package belongs to. References to other 'ess-*' packages as dependencies, workspace siblings, or import targets ('from ess_browser import ...', '[tool.uv.sources] ess-browser = { workspace = true }') are expected and correct. - Generic uv/Python workspace conventions ('[tool.uv.workspace]', 'uv sync --all-packages', the 'packages/python/' and 'tools/python/' directory layout). These are upstream uv patterns, not company-specific. + - Public third-party product names that the package legitimately wraps. For example, 'outlook' / 'microsoft graph' in 'ess-outlook' (Microsoft's public Outlook + Graph API), 'webex' in 'ess-webex' (Cisco's public Webex API at 'webexapis.com'), 'servicenow' in 'ess-service-now-incident'. These describe what the package integrates with. Customer-specific tenant URLs ('.webex.com', '.service-now.com') and Cisco-internal infrastructure ('*.cisco.com', 'cec', 'myid', internal codenames) ARE still problems and should be flagged. + - Public third-party documentation and developer-portal URLs that point readers at upstream docs (e.g. 'https://developer.webex.com/...', 'https://learn.microsoft.com/...', 'https://docs.servicenow.com/...', 'https://graph.microsoft.com', 'https://login.microsoftonline.com'). These are vendor-public, intentionally clickable, and useful to open-source readers -- treat them like any other public dependency URL. Output ONLY a single JSON object, with no surrounding prose, no markdown code fences, and no commentary. Schema: diff --git a/uv.lock b/uv.lock index 6c601b4..7cda940 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,11 @@ members = [ "core-github", "ess-auth", "ess-browser", + "ess-dirs", + "ess-messages", + "ess-outlook", "ess-service-now-incident", + "ess-webex", "essentials", "gcp-gemini", "langsmith-client", @@ -17,6 +21,83 @@ members = [ "pulumi-utils", ] +[[package]] +name = "activitystreams2" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/71/5b50a0088168ab7dbc908aa9b89f6cb298ca5bc308560019fdf88bbcede3/activitystreams2-0.5.0.tar.gz", hash = "sha256:a293bef8f039260e0242accab73de2a47ac6ba153c971e6c30394b659507eb41", size = 5621, upload-time = "2019-06-03T22:31:07.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b3/981b5cc5af966e9c3bcb4ca4d6365fa3a91c64b8e6b0d9ca38a180b005b5/activitystreams2-0.5.0-py3-none-any.whl", hash = "sha256:f99cadc844e4afd7e2244a62cf1f902e78051ea8a4889f8771ae0000ff0dd572", size = 10722, upload-time = "2019-06-03T22:31:09.078Z" }, +] + +[[package]] +name = "aenum" +version = "3.1.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -422,6 +503,61 @@ requires-dist = [{ name = "playwright", specifier = ">=1.40.0" }] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.0" }] +[[package]] +name = "ess-dirs" +version = "0.1.0" +source = { editable = "packages/python/ess-dirs" } + +[[package]] +name = "ess-messages" +version = "0.1.0" +source = { editable = "examples/python/ess-messages" } +dependencies = [ + { name = "activitystreams2" }, + { name = "click" }, + { name = "ess-outlook" }, + { name = "ess-webex" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "activitystreams2", specifier = ">=0.5.0" }, + { name = "click", specifier = ">=8.1" }, + { name = "ess-outlook", editable = "packages/python/ess-outlook" }, + { name = "ess-webex", editable = "packages/python/ess-webex" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] + +[[package]] +name = "ess-outlook" +version = "0.1.0" +source = { editable = "packages/python/ess-outlook" } +dependencies = [ + { name = "click" }, + { name = "ess-dirs" }, + { name = "msal" }, + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1" }, + { name = "ess-dirs", editable = "packages/python/ess-dirs" }, + { name = "msal", specifier = ">=1.28.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "ess-service-now-incident" version = "0.1.0" @@ -445,6 +581,33 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.0" }] +[[package]] +name = "ess-webex" +version = "0.1.0" +source = { editable = "packages/python/ess-webex" } +dependencies = [ + { name = "click" }, + { name = "ess-dirs" }, + { name = "python-dotenv" }, + { name = "wxc-sdk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1" }, + { name = "ess-dirs", editable = "packages/python/ess-dirs" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "wxc-sdk", specifier = ">=1.32.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "essentials" version = "0.1.0" @@ -479,6 +642,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "gcp-gemini" version = "0.1.0" @@ -819,6 +1007,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "isort" version = "6.1.0" @@ -926,6 +1123,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msal" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1089,6 +1327,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "proto-plus" version = "1.27.1" @@ -1558,6 +1822,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -1746,3 +2022,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "wxc-sdk" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aenum" }, + { name = "aiohttp" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/db/18b4a42476b20fc4ec4b78fc92eae59687bf9de26269a7c78941ac50ee62/wxc_sdk-1.34.0.tar.gz", hash = "sha256:68287b4648db621c593b57938f17547e74ef5f83774c1d41a390a290a42f2411", size = 650538, upload-time = "2026-04-22T17:49:12.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/04/86ab8349a02b43340eae36f275c94712f178a55a9b9c4842864dc74becf6/wxc_sdk-1.34.0-py3-none-any.whl", hash = "sha256:afb827965665412546663ddc16e5f5935607565b3d7c46820ada5a1b5ada2697", size = 796634, upload-time = "2026-04-22T17:49:09.161Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +]