loopview-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """loopview-sdk: connect an agent to loopview.
2
+
3
+ import loopview_sdk
4
+ loopview_sdk.connect() # exporter, instrumentation, content, flush at exit
5
+
6
+ with loopview_sdk.agent("name"): # only for hand-written loops with no agent span
7
+ ...
8
+
9
+ @loopview_sdk.tool # optional: exact timing and errors for your tools
10
+ def get_weather(city): ...
11
+
12
+ LiveStartProcessor, the start reporter connect() adds, can also be used on its own.
13
+ """
14
+
15
+ from loopview_sdk._live import LiveStartProcessor
16
+ from loopview_sdk.connect import agent, rank_instrumentors, connect, tool
17
+
18
+ __all__ = ["LiveStartProcessor", "agent", "rank_instrumentors", "connect", "tool"]
19
+ __version__ = "0.2.0"
loopview_sdk/_live.py ADDED
@@ -0,0 +1,136 @@
1
+ """loopview-sdk: tell loopview when spans *start*, for true real-time views.
2
+
3
+ Standard OpenTelemetry exporters send a span only when it ends, so on its own
4
+ loopview sees a step only after it finished (it infers running parents from
5
+ their finished children). Add this processor next to your normal exporter and
6
+ loopview also hears about every span the moment it starts:
7
+
8
+ from loopview_sdk import LiveStartProcessor
9
+ provider.add_span_processor(LiveStartProcessor())
10
+
11
+ It only reports starts; your normal exporter still sends the finished spans.
12
+ Reports are batched every 50 ms and sent from a background thread, so the
13
+ agent never waits on loopview. If loopview isn't running, reports are dropped.
14
+
15
+ Dependencies: opentelemetry-sdk only (HTTP is done with the standard library).
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import queue
21
+ import threading
22
+ import urllib.request
23
+ from typing import Any
24
+
25
+ from opentelemetry.context import Context
26
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
27
+
28
+ DEFAULT_ENDPOINT = "http://127.0.0.1:4318/v1/loopview/span-starts"
29
+ _KIND = {"INTERNAL": 1, "SERVER": 2, "CLIENT": 3, "PRODUCER": 4, "CONSUMER": 5}
30
+
31
+
32
+ class LiveStartProcessor(SpanProcessor):
33
+ def __init__(self, endpoint: str | None = None, interval_s: float = 0.05) -> None:
34
+ self.endpoint = endpoint or os.environ.get("LOOPVIEW_STARTS_ENDPOINT", DEFAULT_ENDPOINT)
35
+ self.interval_s = interval_s
36
+ self._queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=10_000)
37
+ self._stop = threading.Event()
38
+ self._thread = threading.Thread(target=self._run, name="loopview-starts", daemon=True)
39
+ self._thread.start()
40
+
41
+ # --- SpanProcessor -----------------------------------------------------------------
42
+
43
+ def on_start(self, span: Span, parent_context: Context | None = None) -> None:
44
+ try:
45
+ self._queue.put_nowait(_encode(span))
46
+ except queue.Full:
47
+ pass # never slow the agent down
48
+
49
+ def on_end(self, span: ReadableSpan) -> None:
50
+ pass # finished spans go through the user's normal exporter
51
+
52
+ def shutdown(self) -> None:
53
+ self._stop.set()
54
+ self._thread.join(timeout=2)
55
+ self._flush()
56
+
57
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
58
+ self._flush()
59
+ return True
60
+
61
+ # --- background sending --------------------------------------------------------------
62
+
63
+ def _run(self) -> None:
64
+ while not self._stop.wait(self.interval_s):
65
+ self._flush()
66
+
67
+ def _flush(self) -> None:
68
+ batch: list[dict[str, Any]] = []
69
+ while True:
70
+ try:
71
+ batch.append(self._queue.get_nowait())
72
+ except queue.Empty:
73
+ break
74
+ if not batch:
75
+ return
76
+ body = json.dumps(_request(batch)).encode()
77
+ request = urllib.request.Request(
78
+ self.endpoint, data=body, headers={"Content-Type": "application/json"}
79
+ )
80
+ try:
81
+ urllib.request.urlopen(request, timeout=2).close()
82
+ except OSError:
83
+ pass # loopview not running: drop, like a best-effort exporter
84
+
85
+
86
+ # --- OTLP/JSON encoding of a just-started span ---------------------------------------------
87
+
88
+
89
+ def _encode(span: Span) -> dict[str, Any]:
90
+ ctx = span.get_span_context()
91
+ encoded: dict[str, Any] = {
92
+ "traceId": format(ctx.trace_id, "032x"),
93
+ "spanId": format(ctx.span_id, "016x"),
94
+ "name": span.name,
95
+ "kind": _KIND.get(span.kind.name, 0),
96
+ "startTimeUnixNano": str(span.start_time or 0),
97
+ "attributes": _kv(dict(span.attributes or {})),
98
+ }
99
+ if span.parent is not None:
100
+ encoded["parentSpanId"] = format(span.parent.span_id, "016x")
101
+ scope = span.instrumentation_scope
102
+ return {
103
+ "span": encoded,
104
+ "resource": dict(span.resource.attributes) if span.resource else {},
105
+ "scope": (scope.name, scope.version or "") if scope else ("", ""),
106
+ }
107
+
108
+
109
+ def _request(batch: list[dict[str, Any]]) -> dict[str, Any]:
110
+ """Group encoded spans by resource and scope into one ExportTraceServiceRequest."""
111
+ groups: dict[str, dict[str, Any]] = {}
112
+ for item in batch:
113
+ key = json.dumps([item["resource"], item["scope"]], sort_keys=True, default=str)
114
+ group = groups.setdefault(key, {
115
+ "resource": {"attributes": _kv(item["resource"])},
116
+ "scopeSpans": [{"scope": {"name": item["scope"][0], "version": item["scope"][1]},
117
+ "spans": []}],
118
+ })
119
+ group["scopeSpans"][0]["spans"].append(item["span"])
120
+ return {"resourceSpans": list(groups.values())}
121
+
122
+
123
+ def _kv(attributes: dict[str, Any]) -> list[dict[str, Any]]:
124
+ return [{"key": k, "value": _value(v)} for k, v in attributes.items()]
125
+
126
+
127
+ def _value(v: Any) -> dict[str, Any]:
128
+ if isinstance(v, bool):
129
+ return {"boolValue": v}
130
+ if isinstance(v, int):
131
+ return {"intValue": str(v)}
132
+ if isinstance(v, float):
133
+ return {"doubleValue": v}
134
+ if isinstance(v, (list, tuple)):
135
+ return {"arrayValue": {"values": [_value(x) for x in v]}}
136
+ return {"stringValue": str(v)}
@@ -0,0 +1,285 @@
1
+ """Send an agent's traces to loopview with one call, whatever it is built with.
2
+
3
+ import loopview_sdk
4
+ loopview_sdk.connect()
5
+
6
+ It does what every agent needs, so nothing else is required in most cases:
7
+ - an OTLP/HTTP exporter to loopview, sending every 100 ms, and LiveStartProcessor
8
+ so steps light up when they start;
9
+ - the instrumentation of every agent framework or model SDK that is installed:
10
+ each OpenTelemetry instrumentor package registers itself (the
11
+ `opentelemetry_instrumentor` entry point), so they are found, not listed here.
12
+ When several cover the same library (OpenInference's and OpenTelemetry's for
13
+ the OpenAI SDK), OpenInference's is tried first: it records message content and
14
+ tool definitions. If it doesn't support the installed version of the library,
15
+ the next one is used, and a library none fits is reported. Pydantic AI
16
+ instruments itself and is turned on directly;
17
+ - message content capture, which some instrumentations leave off by default;
18
+ - a flush at exit, so a short script doesn't lose its last spans.
19
+
20
+ A hand-written loop on a model SDK has no span around it: each model call would be
21
+ a run of its own. Wrap the loop in `loopview_sdk.agent("name")` to make it one run.
22
+ Its tool calls are rebuilt from the conversation by loopview; decorate the tools
23
+ with `@loopview_sdk.tool` for exact timing and errors (all of them, or none).
24
+ """
25
+
26
+ import atexit
27
+ import functools
28
+ import inspect
29
+ import json
30
+ import os
31
+ from collections.abc import Callable, Iterator, Sequence
32
+ from contextlib import contextmanager
33
+ from importlib.metadata import EntryPoint, entry_points
34
+ from typing import Any, TypeVar, overload
35
+
36
+ from opentelemetry import trace
37
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
38
+ from opentelemetry.sdk.resources import Resource
39
+ from opentelemetry.sdk.trace import TracerProvider
40
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
41
+
42
+ from loopview_sdk._live import LiveStartProcessor
43
+
44
+ DEFAULT_URL = "http://127.0.0.1:4318"
45
+
46
+ # Instrumentors for agent frameworks and model SDKs, besides every OpenInference
47
+ # package: OpenTelemetry's own GenAI instrumentations and OpenLLMetry's register
48
+ # under these names. Other instrumentors (HTTP clients, databases) are left alone.
49
+ GENAI_NAMES = frozenset({
50
+ "openai", "anthropic", "langchain", "llama_index", "bedrock", "vertexai",
51
+ "google_genai", "mistralai", "groq", "cohere", "ollama", "crewai", "litellm",
52
+ "openai_agents", "haystack", "dspy", "smolagents", "autogen", "agno", "mcp",
53
+ })
54
+
55
+ # Content capture, for instrumentations that leave it off by default. Only set
56
+ # when the variable isn't set already: a user's choice always wins.
57
+ CONTENT_DEFAULTS = {
58
+ # OpenTelemetry's GenAI instrumentations (openai-v2, ...): content on spans,
59
+ # where loopview reads it, rather than as log events.
60
+ "OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
61
+ "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY",
62
+ # OpenLLMetry
63
+ "TRACELOOP_TRACE_CONTENT": "true",
64
+ }
65
+
66
+
67
+ def connect(
68
+ service_name: str | None = None,
69
+ url: str | None = None,
70
+ instrument: bool | Sequence[str] = True,
71
+ capture_content: bool = True,
72
+ live: bool = True,
73
+ provider: TracerProvider | None = None,
74
+ quiet: bool = False,
75
+ ) -> TracerProvider:
76
+ """Send this process's traces to loopview. Returns the TracerProvider used.
77
+
78
+ service_name: shown under each run (default: the script's name).
79
+ url: loopview's address. Default: LOOPVIEW_URL, else the standard
80
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT if set, else http://127.0.0.1:4318.
81
+ instrument: True for every installed framework, False for none, or names
82
+ such as ["openai", "langchain"].
83
+ provider: a TracerProvider of your own to add loopview's processors to.
84
+ By default the global one is used if it is already an SDK
85
+ provider, otherwise one is created and made global.
86
+ """
87
+ standard = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "").removesuffix("/v1/traces")
88
+ base = (url or os.environ.get("LOOPVIEW_URL") or standard or DEFAULT_URL).rstrip("/")
89
+ if capture_content:
90
+ for key, value in CONTENT_DEFAULTS.items():
91
+ os.environ.setdefault(key, value)
92
+
93
+ provider, owned = _provider(provider, service_name)
94
+ provider.add_span_processor(
95
+ BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{base}/v1/traces"), schedule_delay_millis=100)
96
+ )
97
+ if live:
98
+ provider.add_span_processor(LiveStartProcessor(endpoint=f"{base}/v1/loopview/span-starts"))
99
+ # Short scripts can end before the exporter's next batch: flush at exit.
100
+ atexit.register(provider.shutdown if owned else provider.force_flush)
101
+
102
+ instrumented, skipped = _instrument(provider, instrument) if instrument else ([], [])
103
+ if not quiet:
104
+ found = ", ".join(instrumented) or "nothing (no supported framework installed)"
105
+ print(f"loopview: sending traces to {base}; instrumented {found}")
106
+ for item in skipped:
107
+ print(f"loopview: could not instrument {item}")
108
+ return provider
109
+
110
+
111
+ @contextmanager
112
+ def agent(name: str, **attributes: Any) -> Iterator[trace.Span]:
113
+ """A span around an agent's work, for loops that have none of their own.
114
+
115
+ with loopview_sdk.agent("weather_assistant"):
116
+ ... # your loop: model calls, tool calls
117
+
118
+ Everything inside becomes one run, drawn as this agent. Works in async code too.
119
+ """
120
+ tracer = trace.get_tracer("loopview_sdk")
121
+ with tracer.start_as_current_span(
122
+ f"invoke_agent {name}",
123
+ attributes={"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": name, **attributes},
124
+ ) as span:
125
+ yield span
126
+
127
+
128
+ F = TypeVar("F", bound=Callable[..., Any])
129
+
130
+
131
+ @overload
132
+ def tool(fn: F) -> F: ...
133
+ @overload
134
+ def tool(*, name: str | None = None) -> Callable[[F], F]: ...
135
+
136
+
137
+ def tool(fn: Callable[..., Any] | None = None, *, name: str | None = None) -> Any:
138
+ """Record each call of a tool function: arguments, result, and errors.
139
+
140
+ @loopview_sdk.tool
141
+ def get_weather(city: str) -> dict: ...
142
+
143
+ Works on async functions too. An exception marks the call as failed and is
144
+ raised again unchanged. Optional: without it, loopview rebuilds tool calls
145
+ from the conversation, with approximate timing and fewer errors.
146
+ """
147
+
148
+ def decorate(f: Callable[..., Any]) -> Callable[..., Any]:
149
+ tool_name = name or f.__name__
150
+ signature = inspect.signature(f)
151
+
152
+ def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
153
+ try:
154
+ bound = signature.bind(*args, **kwargs)
155
+ arguments = _json(dict(bound.arguments))
156
+ except TypeError:
157
+ arguments = _json({"args": list(args), **kwargs})
158
+ return trace.get_tracer("loopview_sdk").start_as_current_span(
159
+ f"execute_tool {tool_name}",
160
+ attributes={
161
+ "gen_ai.operation.name": "execute_tool",
162
+ "gen_ai.tool.name": tool_name,
163
+ "gen_ai.tool.type": "function",
164
+ "gen_ai.tool.call.arguments": arguments,
165
+ },
166
+ record_exception=True,
167
+ set_status_on_exception=True,
168
+ )
169
+
170
+ if inspect.iscoroutinefunction(f):
171
+
172
+ @functools.wraps(f)
173
+ async def run_async(*args: Any, **kwargs: Any) -> Any:
174
+ with start(args, kwargs) as span:
175
+ result = await f(*args, **kwargs)
176
+ span.set_attribute("gen_ai.tool.call.result", _json(result))
177
+ return result
178
+
179
+ return run_async
180
+
181
+ @functools.wraps(f)
182
+ def run(*args: Any, **kwargs: Any) -> Any:
183
+ with start(args, kwargs) as span:
184
+ result = f(*args, **kwargs)
185
+ span.set_attribute("gen_ai.tool.call.result", _json(result))
186
+ return result
187
+
188
+ return run
189
+
190
+ return decorate(fn) if fn is not None else decorate
191
+
192
+
193
+ def _json(value: Any) -> str:
194
+ try:
195
+ return json.dumps(value, default=str)
196
+ except (TypeError, ValueError):
197
+ return str(value)
198
+
199
+
200
+ # --- helpers -------------------------------------------------------------------------------
201
+
202
+
203
+ def _provider(given: TracerProvider | None, service_name: str | None) -> tuple[TracerProvider, bool]:
204
+ """The provider to use, and whether connect() created it."""
205
+ if given is not None:
206
+ return given, False
207
+ current = trace.get_tracer_provider()
208
+ if isinstance(current, TracerProvider):
209
+ return current, False
210
+ name = service_name or os.environ.get("OTEL_SERVICE_NAME") or _script_name()
211
+ provider = TracerProvider(resource=Resource.create({"service.name": name}))
212
+ trace.set_tracer_provider(provider)
213
+ return provider, True
214
+
215
+
216
+ def _script_name() -> str:
217
+ import __main__
218
+
219
+ path = getattr(__main__, "__file__", None)
220
+ return os.path.splitext(os.path.basename(path))[0] if path else "agent"
221
+
222
+
223
+ def rank_instrumentors(
224
+ points: Sequence[EntryPoint], only: Sequence[str] | None = None
225
+ ) -> dict[str, list[EntryPoint]]:
226
+ """The instrumentors for each GenAI library, best first: OpenInference's (it
227
+ records content and tool definitions), then the others in the order found."""
228
+ ranked: dict[str, list[EntryPoint]] = {}
229
+ for ep in points:
230
+ if not _is_genai(ep) or (only is not None and ep.name not in only):
231
+ continue
232
+ ranked.setdefault(ep.name, []).append(ep)
233
+ for candidates in ranked.values():
234
+ candidates.sort(key=lambda ep: not _is_openinference(ep)) # stable
235
+ return ranked
236
+
237
+
238
+ def _is_openinference(ep: EntryPoint) -> bool:
239
+ return bool(ep.dist and ep.dist.name.startswith("openinference-instrumentation-"))
240
+
241
+
242
+ def _is_genai(ep: EntryPoint) -> bool:
243
+ return _is_openinference(ep) or ep.name in GENAI_NAMES
244
+
245
+
246
+ def _instrument(provider: TracerProvider, which: bool | Sequence[str]) -> tuple[list[str], list[str]]:
247
+ """Instrument every installed GenAI library. Returns (instrumented, skipped), the
248
+ skipped ones with the reason: no instrumentor fits the installed version."""
249
+ only = None if which is True else list(which) # type: ignore[arg-type]
250
+ done: list[str] = []
251
+ skipped: list[str] = []
252
+ ranked = rank_instrumentors(list(entry_points(group="opentelemetry_instrumentor")), only)
253
+ for library, candidates in ranked.items():
254
+ reasons = []
255
+ for ep in candidates:
256
+ try:
257
+ instrumentor = ep.load()()
258
+ # The instrumentor's own check: is a version it supports installed?
259
+ conflict = instrumentor._check_dependency_conflicts()
260
+ if conflict is not None:
261
+ if getattr(conflict, "found", None): # installed, wrong version
262
+ reasons.append(f"{ep.dist.name if ep.dist else ep.name}: {conflict}")
263
+ continue
264
+ if not getattr(instrumentor, "is_instrumented_by_opentelemetry", False):
265
+ instrumentor.instrument(tracer_provider=provider)
266
+ done.append(library if _is_openinference(ep) else f"{library} ({ep.dist.name if ep.dist else ep.value})")
267
+ break
268
+ except Exception as exc: # a broken instrumentor must never stop the agent
269
+ reasons.append(f"{ep.dist.name if ep.dist else ep.name}: {exc}")
270
+ else:
271
+ if reasons:
272
+ skipped.append(f"{library} ({'; '.join(reasons)})")
273
+ if (only is None or "pydantic_ai" in only) and _instrument_pydantic_ai(provider):
274
+ done.append("pydantic_ai")
275
+ return done, skipped
276
+
277
+
278
+ def _instrument_pydantic_ai(provider: TracerProvider) -> bool:
279
+ try:
280
+ from pydantic_ai import Agent
281
+ from pydantic_ai.models.instrumented import InstrumentationSettings
282
+ except ImportError:
283
+ return False
284
+ Agent.instrument_all(InstrumentationSettings(tracer_provider=provider))
285
+ return True
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: loopview-sdk
3
+ Version: 0.2.0
4
+ Summary: Connect any agent to loopview in one call, and see steps the moment they start.
5
+ Keywords: ai-agents,llm,opentelemetry,observability,debugging,tracing
6
+ Author: YasmineZerai
7
+ Author-email: YasmineZerai <zeraiyasmine7@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Debuggers
16
+ Classifier: Topic :: System :: Monitoring
17
+ Requires-Dist: opentelemetry-sdk>=1.20
18
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20
19
+ Requires-Python: >=3.11
20
+ Project-URL: Homepage, https://github.com/YasmineZerai/loopview
21
+ Project-URL: Documentation, https://github.com/YasmineZerai/loopview#connect-your-agent
22
+ Project-URL: Issues, https://github.com/YasmineZerai/loopview/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # loopview-sdk
26
+
27
+ Connect any Python agent to [loopview](https://github.com/YasmineZerai/loopview) in one call.
28
+
29
+ ```python
30
+ import loopview_sdk
31
+ loopview_sdk.connect()
32
+ ```
33
+
34
+ `connect()` does what every agent needs:
35
+
36
+ - sends traces to loopview (`LOOPVIEW_URL`, else `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
37
+ else `http://127.0.0.1:4318`), every 100 ms;
38
+ - switches on the OpenTelemetry instrumentation of every installed agent framework or
39
+ model SDK. Instrumentor packages register themselves, so any OpenInference package,
40
+ OpenTelemetry's GenAI instrumentations and OpenLLMetry are found; when two cover the
41
+ same library, OpenInference's is used. Pydantic AI is switched on directly;
42
+ - turns on message content capture where an instrumentation leaves it off;
43
+ - reports span starts, so steps light up the moment they begin;
44
+ - flushes at exit, so short scripts don't lose their last spans.
45
+
46
+ Options: `connect(service_name=..., url=..., instrument=["openai"] or False,
47
+ capture_content=False, live=False, provider=my_provider, quiet=True)`.
48
+
49
+ ## A loop you wrote yourself
50
+
51
+ Model SDK instrumentation records model calls only. Wrap your loop so it is one run,
52
+ drawn as an agent:
53
+
54
+ ```python
55
+ with loopview_sdk.agent("weather_assistant"):
56
+ ... # your loop
57
+ ```
58
+
59
+ ## Only the live start reports
60
+
61
+ `LiveStartProcessor` is the start reporter `connect()` adds, usable on its own next to
62
+ your own exporter:
63
+
64
+ ```python
65
+ from loopview_sdk import LiveStartProcessor
66
+ provider.add_span_processor(LiveStartProcessor())
67
+ ```
@@ -0,0 +1,6 @@
1
+ loopview_sdk/__init__.py,sha256=dBJSePEGS8l7-YcOi4X40zrhjO7bTTYXz0_SyzXbrZI,701
2
+ loopview_sdk/_live.py,sha256=A3ZX9IdUidLupQr37tXBT3u6KpFmvkXjkHvM5242odY,5133
3
+ loopview_sdk/connect.py,sha256=6syDJyDDZ0QoMWFTz-OMI-dnYEhvh_z_TfxEFnk77ms,12036
4
+ loopview_sdk-0.2.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
5
+ loopview_sdk-0.2.0.dist-info/METADATA,sha256=M8pIo0A3jUwFjSKzbTl17BY2DC3jarbps2LvW1sv0K0,2591
6
+ loopview_sdk-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any