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.
Files changed (84) hide show
  1. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/PKG-INFO +1 -1
  2. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/__init__.py +1 -1
  3. athena_python_pptx-0.1.77/pptx/_ptc.py +138 -0
  4. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/batching.py +1 -0
  5. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/slides.py +13 -1
  6. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/__init__.py +15 -1
  7. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pyproject.toml +1 -1
  8. athena_python_pptx-0.1.76/pptx/_ptc.py +0 -173
  9. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/.gitignore +0 -0
  10. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/API_PARITY_REPORT.md +0 -0
  11. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/CHANGELOG.md +0 -0
  12. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/CLAUDE.md +0 -0
  13. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/DEV-GUIDE.md +0 -0
  14. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/PARITY_QUESTIONS.md +0 -0
  15. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/PUBLISHING.md +0 -0
  16. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/README.md +0 -0
  17. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/docs/API_PARITY_EXCEPTIONS.md +0 -0
  18. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/docs/athena-api.json +0 -0
  19. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/docs/athena-api.md +0 -0
  20. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/action.py +0 -0
  21. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/__init__.py +0 -0
  22. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/axis.py +0 -0
  23. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/category.py +0 -0
  24. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/chart.py +0 -0
  25. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/data.py +0 -0
  26. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/datalabel.py +0 -0
  27. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/legend.py +0 -0
  28. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/marker.py +0 -0
  29. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/plot.py +0 -0
  30. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/point.py +0 -0
  31. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/series.py +0 -0
  32. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/chart/xlsx.py +0 -0
  33. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/client.py +0 -0
  34. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/commands.py +0 -0
  35. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/decorators.py +0 -0
  36. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/__init__.py +0 -0
  37. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/chtfmt.py +0 -0
  38. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/color.py +0 -0
  39. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/effect.py +0 -0
  40. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/fill.py +0 -0
  41. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/dml/line.py +0 -0
  42. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/docgen.py +0 -0
  43. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/__init__.py +0 -0
  44. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/action.py +0 -0
  45. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/chart.py +0 -0
  46. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/dml.py +0 -0
  47. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/lang.py +0 -0
  48. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/shapes.py +0 -0
  49. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/enum/text.py +0 -0
  50. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/errors.py +0 -0
  51. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/exc.py +0 -0
  52. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/media.py +0 -0
  53. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/package.py +0 -0
  54. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/__init__.py +0 -0
  55. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/_base.py +0 -0
  56. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/chart.py +0 -0
  57. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/coreprops.py +0 -0
  58. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/embeddedpackage.py +0 -0
  59. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/image.py +0 -0
  60. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/media.py +0 -0
  61. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/presentation.py +0 -0
  62. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/parts/slide.py +0 -0
  63. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/presentation.py +0 -0
  64. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/__init__.py +0 -0
  65. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/autoshape.py +0 -0
  66. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/base.py +0 -0
  67. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/connector.py +0 -0
  68. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/freeform.py +0 -0
  69. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/graphfrm.py +0 -0
  70. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/group.py +0 -0
  71. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/picture.py +0 -0
  72. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/placeholder.py +0 -0
  73. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shapes/shapetree.py +0 -0
  74. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/shared.py +0 -0
  75. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/slide.py +0 -0
  76. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/spec.py +0 -0
  77. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/table.py +0 -0
  78. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/fonts.py +0 -0
  79. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/layout.py +0 -0
  80. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/text/text.py +0 -0
  81. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/types.py +0 -0
  82. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/typing.py +0 -0
  83. {athena_python_pptx-0.1.76 → athena_python_pptx-0.1.77}/pptx/units.py +0 -0
  84. {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.76
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
@@ -127,7 +127,7 @@ def flush_all() -> None:
127
127
  _active_buffers[:] = alive
128
128
 
129
129
 
130
- __version__ = "0.1.76"
130
+ __version__ = "0.1.77"
131
131
 
132
132
  __all__ = [
133
133
  # Main entry point
@@ -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
+ )
@@ -130,6 +130,7 @@ class CommandBuffer:
130
130
  command._ptc_call_id = _ptc.emit_begin( # type: ignore[attr-defined]
131
131
  type(command).__name__,
132
132
  command.to_dict(),
133
+ asset_id=self._deck_id,
133
134
  )
134
135
  except Exception: # noqa: BLE001
135
136
  pass
@@ -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
- # Ensure at least one run per paragraph
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.76"
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"]