athena-python-pptx 0.1.76__tar.gz → 0.1.77__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/PKG-INFO +1 -1
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/__init__.py +1 -1
- athena_python_pptx-0.1.77/pptx/_ptc.py +138 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/batching.py +1 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/slides.py +13 -1
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/__init__.py +15 -1
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pyproject.toml +1 -1
- athena_python_pptx-0.1.76/pptx/_ptc.py +0 -173
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/.gitignore +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/API_PARITY_REPORT.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/CHANGELOG.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/CLAUDE.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/DEV-GUIDE.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/PARITY_QUESTIONS.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/PUBLISHING.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/README.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/docs/API_PARITY_EXCEPTIONS.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/docs/athena-api.json +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/docs/athena-api.md +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/action.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/__init__.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/axis.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/category.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/chart.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/data.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/datalabel.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/legend.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/marker.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/plot.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/point.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/series.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/xlsx.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/client.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/commands.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/decorators.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/__init__.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/chtfmt.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/color.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/effect.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/fill.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/line.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/docgen.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/__init__.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/action.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/chart.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/dml.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/lang.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/shapes.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/text.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/errors.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/exc.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/media.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/package.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/__init__.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/_base.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/chart.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/coreprops.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/embeddedpackage.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/image.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/media.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/presentation.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/slide.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/presentation.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/__init__.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/autoshape.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/base.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/connector.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/freeform.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/graphfrm.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/group.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/picture.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/placeholder.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/shapetree.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shared.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/slide.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/spec.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/table.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/fonts.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/layout.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/text.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/types.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/typing.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/units.py +0 -0
- {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: athena-python-pptx
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.77
|
|
4
4
|
Summary: Drop-in replacement for python-pptx that connects to PPTX Studio for real-time collaboration
|
|
5
5
|
Project-URL: Homepage, https://github.com/pptx-studio/python-sdk
|
|
6
6
|
Project-URL: Documentation, https://docs.pptx-studio.com/sdk/python
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Programmatic Tool Calling (PTC) — client side.
|
|
2
|
+
|
|
3
|
+
Activated only when ``ATHENA_PTC_URL`` is set. The URL is a presigned
|
|
4
|
+
endpoint URL with an HMAC-signed token embedded as ``?t=…``; the token
|
|
5
|
+
carries the (thread, parent_tool_call_id, run) triple and an expiry.
|
|
6
|
+
The SDK doesn't need to know that triple — it just POSTs to the URL
|
|
7
|
+
verbatim, and the server derives identity from the token.
|
|
8
|
+
|
|
9
|
+
Without ``ATHENA_PTC_URL`` set, every call here is a no-op.
|
|
10
|
+
|
|
11
|
+
Emits are fire-and-forget on a background daemon thread:
|
|
12
|
+
|
|
13
|
+
- Never raise into user code.
|
|
14
|
+
- Never block the calling thread.
|
|
15
|
+
- Re-read ``ATHENA_PTC_URL`` on every emit so updates between sandbox
|
|
16
|
+
executions take effect immediately (the SDK module itself is cached
|
|
17
|
+
across runs).
|
|
18
|
+
- Snapshot the URL at ``emit_begin`` time and carry it to ``emit_end``
|
|
19
|
+
so late end events can't be misattributed if a new sandbox run
|
|
20
|
+
swapped in a different URL between begin and end.
|
|
21
|
+
|
|
22
|
+
This module is *intentionally byte-identical* across the docx / pptx /
|
|
23
|
+
xlsx SDKs — the three SDKs have no shared base. Duplicating ~100 LOC
|
|
24
|
+
of stdlib code is cheaper than spinning up a 4th release pipeline.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import queue
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
import uuid
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
_MAX_QUEUE = 4096
|
|
40
|
+
_MAX_BODY = 64 * 1024
|
|
41
|
+
_HTTP_TIMEOUT = 2.0
|
|
42
|
+
|
|
43
|
+
# Singleton state. PTC has exactly one outbox per process.
|
|
44
|
+
_outbox: queue.Queue[tuple[str, dict[str, Any]]] = queue.Queue(maxsize=_MAX_QUEUE)
|
|
45
|
+
_thread_lock = threading.Lock()
|
|
46
|
+
_thread_started = False
|
|
47
|
+
# call_id -> URL snapshot at begin time; lets emit_end target the
|
|
48
|
+
# original run's endpoint even if ATHENA_PTC_URL changed since.
|
|
49
|
+
_call_url: dict[str, str] = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _drain() -> None:
|
|
53
|
+
while True:
|
|
54
|
+
item = _outbox.get()
|
|
55
|
+
if item is None:
|
|
56
|
+
return
|
|
57
|
+
url, body = item
|
|
58
|
+
try:
|
|
59
|
+
raw = json.dumps(body).encode("utf-8")
|
|
60
|
+
if len(raw) > _MAX_BODY:
|
|
61
|
+
key = "args" if body.get("phase") == "begin" else "result"
|
|
62
|
+
body[key] = {"__truncated__": True}
|
|
63
|
+
raw = json.dumps(body).encode("utf-8")
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
url,
|
|
66
|
+
data=raw,
|
|
67
|
+
method="POST",
|
|
68
|
+
headers={"Content-Type": "application/json"},
|
|
69
|
+
)
|
|
70
|
+
urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT).close()
|
|
71
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
72
|
+
pass # never propagate
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ensure_thread() -> None:
|
|
76
|
+
global _thread_started
|
|
77
|
+
if _thread_started:
|
|
78
|
+
return
|
|
79
|
+
with _thread_lock:
|
|
80
|
+
if _thread_started:
|
|
81
|
+
return
|
|
82
|
+
threading.Thread(target=_drain, name="athena-ptc", daemon=True).start()
|
|
83
|
+
_thread_started = True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _send(url: str, body: dict[str, Any]) -> None:
|
|
87
|
+
_ensure_thread()
|
|
88
|
+
try:
|
|
89
|
+
_outbox.put_nowait((url, body))
|
|
90
|
+
except queue.Full:
|
|
91
|
+
pass # drop on backpressure; never block user code
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def emit_begin(
|
|
95
|
+
tool_name: str,
|
|
96
|
+
args: dict[str, Any],
|
|
97
|
+
*,
|
|
98
|
+
asset_id: str | None = None,
|
|
99
|
+
) -> str:
|
|
100
|
+
call_id = uuid.uuid4().hex
|
|
101
|
+
url = os.environ.get("ATHENA_PTC_URL")
|
|
102
|
+
if not url:
|
|
103
|
+
return call_id
|
|
104
|
+
_call_url[call_id] = url
|
|
105
|
+
body: dict[str, Any] = {
|
|
106
|
+
"callId": call_id,
|
|
107
|
+
"toolName": tool_name,
|
|
108
|
+
"phase": "begin",
|
|
109
|
+
"args": args,
|
|
110
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
|
111
|
+
}
|
|
112
|
+
if asset_id is not None:
|
|
113
|
+
body["assetId"] = asset_id
|
|
114
|
+
_send(url, body)
|
|
115
|
+
return call_id
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def emit_end(
|
|
119
|
+
*,
|
|
120
|
+
call_id: str,
|
|
121
|
+
tool_name: str,
|
|
122
|
+
result: dict[str, Any] | None,
|
|
123
|
+
is_error: bool,
|
|
124
|
+
) -> None:
|
|
125
|
+
url = _call_url.pop(call_id, None)
|
|
126
|
+
if url is None:
|
|
127
|
+
return # PTC was disabled at begin, or begin wasn't emitted
|
|
128
|
+
_send(
|
|
129
|
+
url,
|
|
130
|
+
{
|
|
131
|
+
"callId": call_id,
|
|
132
|
+
"toolName": tool_name,
|
|
133
|
+
"phase": "end",
|
|
134
|
+
"result": result,
|
|
135
|
+
"isError": is_error,
|
|
136
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
|
137
|
+
},
|
|
138
|
+
)
|
|
@@ -6,6 +6,7 @@ Provides python-pptx-compatible Slide and Slides collection abstractions.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
import re
|
|
9
|
+
import warnings
|
|
9
10
|
from typing import TYPE_CHECKING, Any, Iterator, Optional, Union
|
|
10
11
|
|
|
11
12
|
if TYPE_CHECKING:
|
|
@@ -2105,7 +2106,9 @@ class SlideLayout:
|
|
|
2105
2106
|
|
|
2106
2107
|
If this layout is attached to a concrete slide, returns that slide's
|
|
2107
2108
|
placeholders. Otherwise, best-effort resolves placeholders from the
|
|
2108
|
-
first slide using this layout index.
|
|
2109
|
+
first slide using this layout index. When no such slide exists yet,
|
|
2110
|
+
emits a ``RuntimeWarning`` and returns an empty collection — the REST
|
|
2111
|
+
snapshot does not materialize layout-level placeholders standalone.
|
|
2109
2112
|
"""
|
|
2110
2113
|
if self._slide is not None:
|
|
2111
2114
|
return self._slide.placeholders
|
|
@@ -2115,6 +2118,15 @@ class SlideLayout:
|
|
|
2115
2118
|
if slide._layout_index == self._index:
|
|
2116
2119
|
return slide.placeholders
|
|
2117
2120
|
|
|
2121
|
+
warnings.warn(
|
|
2122
|
+
f"SlideLayout[{self._index}] ({self.name!r}) has no placeholders to "
|
|
2123
|
+
f"enumerate yet — the REST snapshot only exposes layout placeholders "
|
|
2124
|
+
f"through a slide that uses the layout. Call "
|
|
2125
|
+
f"prs.slides.add_slide(layout) and prs.refresh(), then read "
|
|
2126
|
+
f"slide.placeholders to see the inherited placeholder set.",
|
|
2127
|
+
RuntimeWarning,
|
|
2128
|
+
stacklevel=2,
|
|
2129
|
+
)
|
|
2118
2130
|
return SlidePlaceholders({})
|
|
2119
2131
|
|
|
2120
2132
|
@property
|
|
@@ -1428,6 +1428,17 @@ class TextFrame:
|
|
|
1428
1428
|
|
|
1429
1429
|
Returns None when all paragraphs have a single run with no hyperlinks,
|
|
1430
1430
|
so the server falls back to the simpler text-split behaviour.
|
|
1431
|
+
|
|
1432
|
+
Note on empty paragraphs (Gap-3): when a paragraph has zero runs
|
|
1433
|
+
(post ``clear()`` or freshly added via ``add_paragraph()``) we
|
|
1434
|
+
intentionally stay on the plain-text path. The server's plain-text
|
|
1435
|
+
SetText creates one empty run per paragraph, which matches what
|
|
1436
|
+
``add_run()`` + ``run.text = …`` would have created and keeps
|
|
1437
|
+
downstream ``SetRunStyle(p, r=0)`` commands valid even before the
|
|
1438
|
+
local SDK has emitted any run-level text. Switching to rich-content
|
|
1439
|
+
with ``runs: []`` here would break the safe ``clear() →
|
|
1440
|
+
add_run() → run.font.size = …`` ordering because ``SetRunStyle``
|
|
1441
|
+
would target a non-existent server run.
|
|
1431
1442
|
"""
|
|
1432
1443
|
needs_rich = False
|
|
1433
1444
|
for para in self._paragraphs:
|
|
@@ -1458,7 +1469,10 @@ class TextFrame:
|
|
|
1458
1469
|
if run._hyperlink._address:
|
|
1459
1470
|
run_data["hyperlinkUrl"] = run._hyperlink._address
|
|
1460
1471
|
runs.append(run_data)
|
|
1461
|
-
#
|
|
1472
|
+
# When a paragraph carries no runs locally (post-clear / freshly
|
|
1473
|
+
# added) the plain-text fallback would create one empty run on
|
|
1474
|
+
# the server. Mirror that here so a rich-content emit doesn't
|
|
1475
|
+
# regress to zero runs and break later SetRunStyle calls.
|
|
1462
1476
|
if not runs:
|
|
1463
1477
|
runs.append({"text": ""})
|
|
1464
1478
|
paragraphs.append({"runs": runs})
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "athena-python-pptx"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.77"
|
|
8
8
|
description = "Drop-in replacement for python-pptx that connects to PPTX Studio for real-time collaboration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
"""Programmatic Tool Calling (PTC) — client side, docx SDK.
|
|
2
|
-
|
|
3
|
-
Activated only when ``ATHENA_PTC_URL`` is set. The URL is a presigned
|
|
4
|
-
endpoint URL with an HMAC-signed token embedded as ``?t=…``; the token
|
|
5
|
-
carries the (thread, parent_tool_call_id, run) triple and an expiry.
|
|
6
|
-
The SDK doesn't need to know that triple — it just POSTs to the URL
|
|
7
|
-
verbatim, and the server derives identity from the token.
|
|
8
|
-
|
|
9
|
-
Without ``ATHENA_PTC_URL`` set, every call here is a no-op.
|
|
10
|
-
|
|
11
|
-
Emits are fire-and-forget on a background daemon thread:
|
|
12
|
-
|
|
13
|
-
- Never raise into user code.
|
|
14
|
-
- Never block the calling thread.
|
|
15
|
-
- Re-read ``ATHENA_PTC_URL`` on every emit so updates between sandbox
|
|
16
|
-
executions take effect immediately (the SDK module itself is cached
|
|
17
|
-
across runs).
|
|
18
|
-
- Snapshot the URL at ``emit_begin`` time and carry it to ``emit_end``
|
|
19
|
-
so late end events can't be misattributed if a new sandbox run
|
|
20
|
-
swapped in a different URL between begin and end.
|
|
21
|
-
|
|
22
|
-
This module is *intentionally* duplicated into the pptx and xlsx SDKs
|
|
23
|
-
— the three SDKs have no shared base. Keeping it byte-identical
|
|
24
|
-
(modulo ``_LIB_NAME``) makes diffs trivial.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
from __future__ import annotations
|
|
28
|
-
|
|
29
|
-
import json
|
|
30
|
-
import os
|
|
31
|
-
import queue
|
|
32
|
-
import threading
|
|
33
|
-
import time
|
|
34
|
-
import urllib.error
|
|
35
|
-
import urllib.request
|
|
36
|
-
import uuid
|
|
37
|
-
from typing import Any
|
|
38
|
-
|
|
39
|
-
_LIB_NAME = "pptx"
|
|
40
|
-
|
|
41
|
-
_MAX_QUEUE = 4096
|
|
42
|
-
_MAX_BODY = 64 * 1024
|
|
43
|
-
_HTTP_TIMEOUT = 2.0
|
|
44
|
-
|
|
45
|
-
_PTC_URL_ENV = "ATHENA_PTC_URL"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _read_url() -> str | None:
|
|
49
|
-
url = os.environ.get(_PTC_URL_ENV)
|
|
50
|
-
return url if url else None
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _utcnow_iso() -> str:
|
|
54
|
-
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class _PTCClient:
|
|
58
|
-
def __init__(self) -> None:
|
|
59
|
-
# Lazy: don't snapshot URL at import time; runs change between
|
|
60
|
-
# sandbox executions and the module is cached in sys.modules.
|
|
61
|
-
self._q: queue.Queue[tuple[str, dict[str, Any]] | None] = queue.Queue(
|
|
62
|
-
maxsize=_MAX_QUEUE,
|
|
63
|
-
)
|
|
64
|
-
self._started = False
|
|
65
|
-
self._lock = threading.Lock()
|
|
66
|
-
# call_id -> URL snapshot at begin time. Lets emit_end target
|
|
67
|
-
# the original run's endpoint even if env changed since.
|
|
68
|
-
self._call_url: dict[str, str] = {}
|
|
69
|
-
|
|
70
|
-
def _ensure_started(self) -> None:
|
|
71
|
-
if self._started:
|
|
72
|
-
return
|
|
73
|
-
with self._lock:
|
|
74
|
-
if self._started:
|
|
75
|
-
return
|
|
76
|
-
t = threading.Thread(
|
|
77
|
-
target=self._drain,
|
|
78
|
-
name=f"athena-{_LIB_NAME}-ptc",
|
|
79
|
-
daemon=True,
|
|
80
|
-
)
|
|
81
|
-
t.start()
|
|
82
|
-
self._started = True
|
|
83
|
-
|
|
84
|
-
def emit_begin(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
85
|
-
call_id = uuid.uuid4().hex
|
|
86
|
-
url = _read_url()
|
|
87
|
-
if url is None:
|
|
88
|
-
return call_id
|
|
89
|
-
self._call_url[call_id] = url
|
|
90
|
-
self._ensure_started()
|
|
91
|
-
body = {
|
|
92
|
-
"callId": call_id,
|
|
93
|
-
"toolName": f"{_LIB_NAME}.{tool_name}",
|
|
94
|
-
"phase": "begin",
|
|
95
|
-
"args": args,
|
|
96
|
-
"ts": _utcnow_iso(),
|
|
97
|
-
}
|
|
98
|
-
self._enqueue(url, body)
|
|
99
|
-
return call_id
|
|
100
|
-
|
|
101
|
-
def emit_end(
|
|
102
|
-
self,
|
|
103
|
-
*,
|
|
104
|
-
call_id: str,
|
|
105
|
-
tool_name: str,
|
|
106
|
-
result: dict[str, Any] | None,
|
|
107
|
-
is_error: bool,
|
|
108
|
-
) -> None:
|
|
109
|
-
url = self._call_url.pop(call_id, None)
|
|
110
|
-
if url is None:
|
|
111
|
-
return # PTC was disabled at begin, or begin wasn't emitted
|
|
112
|
-
body = {
|
|
113
|
-
"callId": call_id,
|
|
114
|
-
"toolName": f"{_LIB_NAME}.{tool_name}",
|
|
115
|
-
"phase": "end",
|
|
116
|
-
"result": result,
|
|
117
|
-
"isError": is_error,
|
|
118
|
-
"ts": _utcnow_iso(),
|
|
119
|
-
}
|
|
120
|
-
self._enqueue(url, body)
|
|
121
|
-
|
|
122
|
-
def _enqueue(self, url: str, body: dict[str, Any]) -> None:
|
|
123
|
-
try:
|
|
124
|
-
self._q.put_nowait((url, body))
|
|
125
|
-
except queue.Full:
|
|
126
|
-
pass # drop on backpressure; never block user code
|
|
127
|
-
|
|
128
|
-
def _drain(self) -> None:
|
|
129
|
-
while True:
|
|
130
|
-
item = self._q.get()
|
|
131
|
-
if item is None:
|
|
132
|
-
return
|
|
133
|
-
url, body = item
|
|
134
|
-
try:
|
|
135
|
-
raw = json.dumps(body).encode("utf-8")
|
|
136
|
-
if len(raw) > _MAX_BODY:
|
|
137
|
-
truncated_key = "args" if body.get("phase") == "begin" else "result"
|
|
138
|
-
body[truncated_key] = {"__truncated__": True}
|
|
139
|
-
raw = json.dumps(body).encode("utf-8")
|
|
140
|
-
req = urllib.request.Request(
|
|
141
|
-
url,
|
|
142
|
-
data=raw,
|
|
143
|
-
method="POST",
|
|
144
|
-
headers={"Content-Type": "application/json"},
|
|
145
|
-
)
|
|
146
|
-
urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT).close()
|
|
147
|
-
except (urllib.error.URLError, OSError, ValueError):
|
|
148
|
-
pass # never propagate
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
_CLIENT = _PTCClient()
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def emit_begin(tool_name: str, args: dict[str, Any]) -> str:
|
|
155
|
-
return _CLIENT.emit_begin(tool_name, args)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def emit_end(
|
|
159
|
-
*,
|
|
160
|
-
call_id: str,
|
|
161
|
-
tool_name: str,
|
|
162
|
-
result: dict[str, Any] | None,
|
|
163
|
-
is_error: bool,
|
|
164
|
-
) -> None:
|
|
165
|
-
_CLIENT.emit_end(
|
|
166
|
-
call_id=call_id,
|
|
167
|
-
tool_name=tool_name,
|
|
168
|
-
result=result,
|
|
169
|
-
is_error=is_error,
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
__all__ = ["emit_begin", "emit_end"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|