firstops 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.
firstops/tools.py ADDED
@@ -0,0 +1,318 @@
1
+ """The base-API tool decorator — govern any callable.
2
+
3
+ ``@firstops.tool`` wraps a function so each call is forwarded to sentinel as a
4
+ ``pre_tool_use`` event (block / modify args) and a ``post_tool_use`` event
5
+ (audit). It is the harness-agnostic floor: it audits everywhere and blocks
6
+ where the surrounding harness propagates a raised exception (per the design's
7
+ Discovery A, raising is the block mechanism for the base layer).
8
+
9
+ Coverage is honest: a decorated tool is governed; an un-decorated one is not.
10
+ For framework built-ins the developer never authored, use the harness adapter
11
+ instead (it hooks the execution boundary).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import inspect
18
+ import json
19
+ import logging
20
+ from typing import Any, Callable
21
+
22
+ from firstops import _runtime
23
+ from firstops.channels import classify, mcp_info
24
+ from firstops.events import (
25
+ CHANNEL_MCP,
26
+ EVENT_POST_TOOL_USE,
27
+ EVENT_PRE_TOOL_USE,
28
+ ActionEvent,
29
+ Decision,
30
+ )
31
+
32
+ logger = logging.getLogger("firstops")
33
+
34
+ # Attributes that mark an already-built framework tool object (LangChain
35
+ # StructuredTool, LlamaIndex FunctionTool, etc.). We refuse to wrap these —
36
+ # the user must decorate the underlying function before the framework wraps it.
37
+ _TOOL_OBJECT_MARKERS = ("invoke", "ainvoke", "args_schema", "_run")
38
+
39
+
40
+ # Names of tools governed by @firstops.tool this process — used by
41
+ # firstops.coverage to reconcile declared-vs-governed and surface gaps.
42
+ _GOVERNED_TOOLS: set[str] = set()
43
+
44
+
45
+ def governed_tool_names() -> set[str]:
46
+ """Return the set of tool names governed by @firstops.tool."""
47
+ return set(_GOVERNED_TOOLS)
48
+
49
+
50
+ class FirstOpsPolicyError(Exception):
51
+ """Raised when sentinel denies a governed tool call."""
52
+
53
+ def __init__(self, tool_name: str, reason: str, policy_id: str = ""):
54
+ self.tool_name = tool_name
55
+ self.reason = reason
56
+ self.policy_id = policy_id
57
+ super().__init__(f"FirstOps blocked tool {tool_name!r}: {reason}")
58
+
59
+
60
+ def tool(fn: Callable | None = None, *, name: str | None = None):
61
+ """Decorator that governs a tool function. Usable as ``@tool`` or ``@tool(name=...)``."""
62
+
63
+ def decorator(func: Callable) -> Callable:
64
+ _check_wrappable(func)
65
+ tool_name = name or getattr(func, "__name__", "tool")
66
+ # Register BOTH the governance name and the function's __name__ so a
67
+ # coverage check that enumerates tools by either key sees it governed.
68
+ _GOVERNED_TOOLS.add(tool_name)
69
+ fn_name = getattr(func, "__name__", None)
70
+ if fn_name:
71
+ _GOVERNED_TOOLS.add(fn_name)
72
+
73
+ # Order matters: async-gen and generator are NOT coroutine functions,
74
+ # so they must be detected first or they'd fall into the sync wrapper
75
+ # and return an un-iterated (async)generator with the body unexecuted.
76
+ if inspect.isasyncgenfunction(func):
77
+ # Govern EAGERLY at call time (the wrapper is a plain function that
78
+ # returns the async generator), so a DENY blocks before any
79
+ # iteration rather than on the first __anext__.
80
+ @functools.wraps(func)
81
+ def agwrapper(*args: Any, **kwargs: Any):
82
+ args, kwargs = _govern(func, tool_name, args, kwargs)
83
+ return _agen_iterate(func, tool_name, args, kwargs)
84
+
85
+ return agwrapper
86
+
87
+ if inspect.isgeneratorfunction(func):
88
+
89
+ @functools.wraps(func)
90
+ def gwrapper(*args: Any, **kwargs: Any):
91
+ args, kwargs = _govern(func, tool_name, args, kwargs)
92
+ return _gen_iterate(func, tool_name, args, kwargs)
93
+
94
+ return gwrapper
95
+
96
+ if inspect.iscoroutinefunction(func):
97
+
98
+ @functools.wraps(func)
99
+ async def awrapper(*args: Any, **kwargs: Any) -> Any:
100
+ args, kwargs = _govern(func, tool_name, args, kwargs)
101
+ result = None
102
+ error: Exception | None = None
103
+ try:
104
+ result = await func(*args, **kwargs)
105
+ return result
106
+ except Exception as e:
107
+ error = e
108
+ raise
109
+ finally:
110
+ _audit_post(tool_name, result, error)
111
+
112
+ return awrapper
113
+
114
+ @functools.wraps(func)
115
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
116
+ args, kwargs = _govern(func, tool_name, args, kwargs)
117
+ result = None
118
+ error: Exception | None = None
119
+ try:
120
+ result = func(*args, **kwargs)
121
+ return result
122
+ except Exception as e:
123
+ error = e
124
+ raise
125
+ finally:
126
+ _audit_post(tool_name, result, error)
127
+
128
+ return wrapper
129
+
130
+ if fn is not None:
131
+ return decorator(fn)
132
+ return decorator
133
+
134
+
135
+ # Sentinel marker for "the result is a stream we didn't materialize".
136
+ _STREAM_SENTINEL = object()
137
+
138
+
139
+ def _gen_iterate(func, tool_name, args, kwargs):
140
+ """Drive a generator tool, auditing once after it's exhausted/aborted."""
141
+ error: Exception | None = None
142
+ try:
143
+ yield from func(*args, **kwargs)
144
+ except Exception as e:
145
+ error = e
146
+ raise
147
+ finally:
148
+ _audit_post(tool_name, _STREAM_SENTINEL, error)
149
+
150
+
151
+ async def _agen_iterate(func, tool_name, args, kwargs):
152
+ """Async analogue of :func:`_gen_iterate`."""
153
+ error: Exception | None = None
154
+ try:
155
+ async for item in func(*args, **kwargs):
156
+ yield item
157
+ except Exception as e:
158
+ error = e
159
+ raise
160
+ finally:
161
+ _audit_post(tool_name, _STREAM_SENTINEL, error)
162
+
163
+
164
+ def _check_wrappable(func: Callable) -> None:
165
+ if (
166
+ inspect.isfunction(func)
167
+ or inspect.ismethod(func)
168
+ or inspect.iscoroutinefunction(func)
169
+ or inspect.isgeneratorfunction(func)
170
+ or inspect.isasyncgenfunction(func)
171
+ ):
172
+ return
173
+ if callable(func) and any(hasattr(func, m) for m in _TOOL_OBJECT_MARKERS):
174
+ raise TypeError(
175
+ f"@firstops.tool expects a plain function, not a built framework tool "
176
+ f"object ({type(func).__name__}). Decorate the underlying function "
177
+ f"before the framework wraps it (put @firstops.tool innermost)."
178
+ )
179
+ if not callable(func):
180
+ raise TypeError(f"@firstops.tool expects a callable, got {type(func).__name__}")
181
+ # Other callables (functools.partial, lambdas) are allowed.
182
+
183
+
184
+ def _govern(
185
+ func: Callable, tool_name: str, args: tuple, kwargs: dict
186
+ ) -> tuple[tuple, dict]:
187
+ """Run the pre_tool_use evaluation and apply the decision to the call args."""
188
+ tool_input = _bind_inputs(func, args, kwargs)
189
+ decision = _evaluate_pre(tool_name, tool_input)
190
+ return _apply_pre(func, decision, tool_name, args, kwargs)
191
+
192
+
193
+ def _jsonable(value: Any) -> Any:
194
+ try:
195
+ json.dumps(value)
196
+ return value
197
+ except (TypeError, ValueError):
198
+ return repr(value)
199
+
200
+
201
+ def _bind_inputs(func: Callable, args: tuple, kwargs: dict) -> dict[str, Any]:
202
+ """Map a call's args/kwargs to a JSON-able {param: value} dict."""
203
+ try:
204
+ bound = inspect.signature(func).bind_partial(*args, **kwargs)
205
+ bound.apply_defaults()
206
+ return {k: _jsonable(v) for k, v in bound.arguments.items()}
207
+ except (TypeError, ValueError):
208
+ out: dict[str, Any] = {f"arg{i}": _jsonable(a) for i, a in enumerate(args)}
209
+ out.update({k: _jsonable(v) for k, v in kwargs.items()})
210
+ return out
211
+
212
+
213
+ def _evaluate_pre(tool_name: str, tool_input: dict[str, Any]) -> Decision | None:
214
+ rt = _runtime.runtime()
215
+ if rt is None:
216
+ return None # not initialized — no governance, run normally
217
+ channel = classify(tool_name)
218
+ event = ActionEvent(
219
+ event_type=EVENT_PRE_TOOL_USE,
220
+ tool_name=tool_name,
221
+ channel=channel,
222
+ tool_input=tool_input,
223
+ mcp=mcp_info(tool_name) if channel == CHANNEL_MCP else None,
224
+ # The decorator rebinds args from a modify payload, so request-path
225
+ # scrub is applicable — let sentinel ship modify, not escalate to deny.
226
+ producer_can_apply_modify=True,
227
+ )
228
+ return rt.enforcement.evaluate(event)
229
+
230
+
231
+ def _apply_pre(
232
+ func: Callable, decision: Decision | None, tool_name: str, args: tuple, kwargs: dict
233
+ ) -> tuple[tuple, dict]:
234
+ if decision is None:
235
+ return args, kwargs
236
+ if decision.blocked:
237
+ raise FirstOpsPolicyError(tool_name, decision.reason, decision.policy_id)
238
+ if decision.modified and decision.modified_payload:
239
+ rebound = _rebind(func, decision.modified_payload, args, kwargs)
240
+ if rebound is not None:
241
+ return rebound
242
+ # We signalled producer_can_apply_modify, so sentinel shipped a scrub
243
+ # instead of a deny. If it doesn't fit the signature, fail CLOSED —
244
+ # running the tool with unscrubbed args is worse than blocking.
245
+ logger.warning(
246
+ "firstops: could not apply modify to %s; blocking (fail closed)", tool_name
247
+ )
248
+ raise FirstOpsPolicyError(
249
+ tool_name,
250
+ "policy required a modification this tool could not apply",
251
+ decision.policy_id,
252
+ )
253
+ return args, kwargs
254
+
255
+
256
+ def _rebind(
257
+ func: Callable, payload: bytes, args: tuple, kwargs: dict
258
+ ) -> tuple[tuple, dict] | None:
259
+ """Overlay sentinel's scrubbed inputs onto the call, respecting param kinds.
260
+
261
+ Returns (args, kwargs) honoring positional-only / *args / **kwargs, or None
262
+ if the payload can't be applied (caller then falls open to original args).
263
+ """
264
+ try:
265
+ new_input = json.loads(payload)
266
+ except (ValueError, TypeError):
267
+ return None
268
+ if not isinstance(new_input, dict):
269
+ return None
270
+ try:
271
+ sig = inspect.signature(func)
272
+ bound = sig.bind_partial(*args, **kwargs)
273
+ bound.apply_defaults()
274
+ merged = dict(bound.arguments)
275
+ merged.update(new_input)
276
+
277
+ out_args: list[Any] = []
278
+ out_kwargs: dict[str, Any] = {}
279
+ consumed = set()
280
+ for pname, param in sig.parameters.items():
281
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
282
+ out_args.extend(merged.get(pname, ()) or ())
283
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
284
+ out_kwargs.update(merged.get(pname, {}) or {})
285
+ elif pname in merged:
286
+ if param.kind == inspect.Parameter.POSITIONAL_ONLY:
287
+ out_args.append(merged[pname])
288
+ else:
289
+ out_kwargs[pname] = merged[pname]
290
+ consumed.add(pname)
291
+ # Validate the reconstruction actually binds before returning it.
292
+ sig.bind(*out_args, **out_kwargs)
293
+ return tuple(out_args), out_kwargs
294
+ except (TypeError, ValueError):
295
+ return None
296
+
297
+
298
+ def _audit_post(tool_name: str, result: Any, error: Exception | None = None) -> None:
299
+ """Emit a post_tool_use audit event. Best-effort: never affects the call."""
300
+ rt = _runtime.runtime()
301
+ if rt is None:
302
+ return
303
+ try:
304
+ if error is not None:
305
+ output: dict[str, Any] = {"error": repr(error)}
306
+ elif result is _STREAM_SENTINEL:
307
+ output = {"result": "<stream>"}
308
+ else:
309
+ output = {"result": _jsonable(result)}
310
+ event = ActionEvent(
311
+ event_type=EVENT_POST_TOOL_USE,
312
+ tool_name=tool_name,
313
+ channel=classify(tool_name),
314
+ tool_output=output,
315
+ )
316
+ rt.enforcement.evaluate(event)
317
+ except Exception as e: # pragma: no cover - defensive
318
+ logger.debug("firstops post-eval failed for %s: %s", tool_name, e)
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: firstops
3
+ Version: 0.2.0
4
+ Summary: Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and OpenAI Agents.
5
+ Project-URL: Homepage, https://firstops.dev
6
+ Project-URL: Documentation, https://github.com/firstops-dev/firstops-python
7
+ Project-URL: Repository, https://github.com/firstops-dev/firstops-python
8
+ Project-URL: Issues, https://github.com/firstops-dev/firstops-python/issues
9
+ Author-email: FirstOps <dev@firstops.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,claude,dpop,governance,guardrails,langchain,langgraph,llm,mcp,openai-agents,security
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: cryptography>=42.0
25
+ Requires-Dist: httpx>=0.27
26
+ Provides-Extra: all
27
+ Requires-Dist: claude-agent-sdk; extra == 'all'
28
+ Requires-Dist: langchain-mcp-adapters; extra == 'all'
29
+ Requires-Dist: langchain-openai; extra == 'all'
30
+ Requires-Dist: langchain>=1.0; extra == 'all'
31
+ Requires-Dist: langgraph; extra == 'all'
32
+ Requires-Dist: openai-agents; extra == 'all'
33
+ Provides-Extra: claude
34
+ Requires-Dist: claude-agent-sdk; extra == 'claude'
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
37
+ Requires-Dist: pytest>=8.0; extra == 'dev'
38
+ Provides-Extra: langgraph
39
+ Requires-Dist: langchain-mcp-adapters; extra == 'langgraph'
40
+ Requires-Dist: langchain-openai; extra == 'langgraph'
41
+ Requires-Dist: langchain>=1.0; extra == 'langgraph'
42
+ Requires-Dist: langgraph; extra == 'langgraph'
43
+ Provides-Extra: openai
44
+ Requires-Dist: openai-agents; extra == 'openai'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # FirstOps Python SDK
48
+
49
+ Govern what your AI agents do. FirstOps applies identity, policy enforcement, credential brokering, and audit to every **LLM call**, **tool call**, and **MCP call** your agent makes — across LangGraph, the Claude Agent SDK, and the OpenAI Agents SDK, or any custom loop.
50
+
51
+ ```bash
52
+ pip install "firstops[langgraph]" # or [claude], [openai], [all]
53
+ ```
54
+
55
+ - **Python 3.10+**
56
+ - Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
57
+
58
+ ---
59
+
60
+ ## What FirstOps governs
61
+
62
+ | Surface | How it's wired | What you get |
63
+ |---|---|---|
64
+ | **LLM calls** | point the model `base_url` at the local sidecar | inspect prompts/responses, scrub PII, block, audit |
65
+ | **Tool calls** | one adapter (or `@firstops.tool`) | block / rewrite args / audit — including framework built-ins |
66
+ | **MCP servers** | point the MCP client at the local proxy | server-side policy + **credential brokering** (the agent never holds the upstream token) |
67
+
68
+ Every action is evaluated by FirstOps and returns `allow` / `deny` / `modify` — your agent logic doesn't change.
69
+
70
+ ## Quick start (LangGraph)
71
+
72
+ ```python
73
+ import firstops
74
+ from firstops.integrations.langgraph import FirstOpsMiddleware
75
+ from langchain.agents import create_agent
76
+ from langchain_openai import ChatOpenAI
77
+
78
+ fo = firstops.init(
79
+ agent_id="<agent-uuid>", # from the FirstOps dashboard
80
+ private_key_pem=open("agent-key.pem").read(),
81
+ )
82
+
83
+ # Route the LLM through FirstOps; wire one middleware to govern every tool call.
84
+ llm = ChatOpenAI(model="gpt-4o-mini", base_url=firstops.llm_base_url("openai"), api_key="sk-...")
85
+ agent = create_agent(model=llm, tools=[...], middleware=[FirstOpsMiddleware(fo)])
86
+
87
+ agent.invoke({"messages": [{"role": "user", "content": "..."}]})
88
+ ```
89
+
90
+ The whole integration is `init()` + a `base_url` swap + one middleware. See [`examples/`](examples/) for runnable agents, including MCP.
91
+
92
+ ## Other harnesses
93
+
94
+ **Claude Agent SDK** — one `PreToolUse` hook governs every tool (built-ins, MCP, custom):
95
+
96
+ ```python
97
+ from claude_agent_sdk import query, ClaudeAgentOptions
98
+ from firstops.integrations.claude import firstops_hooks
99
+
100
+ options = ClaudeAgentOptions(hooks=firstops_hooks(fo), permission_mode="bypassPermissions")
101
+ async for _ in query(prompt="...", options=options):
102
+ pass
103
+ ```
104
+
105
+ **OpenAI Agents SDK** — a guardrail per tool + the model routed through the sidecar:
106
+
107
+ ```python
108
+ from agents import Agent, function_tool, set_default_openai_client
109
+ from firstops.integrations.openai_agents import firstops_tool_input_guardrail
110
+ from openai import AsyncOpenAI
111
+
112
+ set_default_openai_client(AsyncOpenAI(base_url=firstops.llm_base_url("openai"), api_key="sk-..."))
113
+ guard = firstops_tool_input_guardrail(fo)
114
+
115
+ @function_tool(tool_input_guardrails=[guard])
116
+ def send_email(to: str, body: str) -> str: ...
117
+ ```
118
+
119
+ **Any framework / custom loop** — the base API:
120
+
121
+ ```python
122
+ @firstops.tool # govern any callable: block / scrub args / audit
123
+ def send_email(to: str, body: str): ...
124
+ ```
125
+
126
+ ## MCP servers
127
+
128
+ Point your MCP client at the local proxy; FirstOps brokers the upstream credentials.
129
+
130
+ ```python
131
+ from langchain_mcp_adapters.client import MultiServerMCPClient
132
+
133
+ mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"), "transport": "streamable_http"}})
134
+ tools = await mcp.get_tools()
135
+ ```
136
+
137
+ ## Management client
138
+
139
+ Provision agents and connections from your backend:
140
+
141
+ ```python
142
+ from firstops import FirstOps
143
+
144
+ admin = FirstOps(api_key="fo_key_...")
145
+ agent = admin.agents.create(name="research-bot") # -> id + private_key (shown once)
146
+ admin.connections.register(principal_id=agent.id, name="slack", upstream_url="https://mcp.slack.com/sse")
147
+ ```
148
+
149
+ ## How it works
150
+
151
+ `firstops.init()` starts a local sidecar and establishes the agent's identity (a DPoP-bound principal — RFC 9449). Tool and LLM actions are forwarded to the FirstOps gateway, which evaluates them against your policies and returns the verdict; MCP and LLM traffic flow through the sidecar with credentials brokered. Enforcement fails open on infrastructure errors; authentication fails closed.
152
+
153
+ ## Documentation
154
+
155
+ - Guides: <https://firstops.dev/docs>
156
+ - Repository: <https://github.com/firstops-dev/firstops-python>
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,21 @@
1
+ firstops/__init__.py,sha256=mkGIkRos4vMr3sOXNM6kWBAyIaY8JvyKQRaH8x3jFMk,1363
2
+ firstops/_identity.py,sha256=SHUwdPsXZOgZBH15MNfk1NcvGK_0sFVVNJixB0Kl_Iw,2171
3
+ firstops/_runtime.py,sha256=YUJ8ZrT-r83W6wFwT79OQlcj7tjB9_xVqdmGIoQmBnQ,4928
4
+ firstops/channels.py,sha256=e14hhV6IYE8I3A9PZdzH59_sSzr7sfxDhkdexqg6etA,1445
5
+ firstops/client.py,sha256=L6CgVbeHx0rrt5_quIfHxAyLMwBGMfQEf6jtTD_QT2c,14413
6
+ firstops/coverage.py,sha256=ri-xyeANYPfzQr5kTreZ7R9ciI9sCEIJ6BQMHO1U-bE,2859
7
+ firstops/dpop.py,sha256=CnrrRalG_JflWvHKXzWhPpfQxB_9aHPaph0GLgqT1CY,2765
8
+ firstops/enforcement.py,sha256=1RxSlPSbUH2Hm1kLOYX7RCyd8cBc8gjTUg4e1l1a-gw,2751
9
+ firstops/events.py,sha256=uiPow8-mefOW3kZz1JHLBkJoyX4ukTMcxQGllQFu0To,6779
10
+ firstops/llm.py,sha256=LUHE_tzgtJ5T-GjY9aVq4yD7fhRmXxCqPblovcl4SSE,1753
11
+ firstops/proxy.py,sha256=-ApBofk5yiOhpOfM7zFPad_PzSWJajUiPhC8GQ_xlPc,16283
12
+ firstops/tools.py,sha256=_-py7p1DayfOFvMD-JeiR-sl581k1KSlg0RuOLonz0c,11642
13
+ firstops/integrations/__init__.py,sha256=ItugNKg3EZ_mEM8hiExAGAUaTMTzYZ6ov0EexWqhemU,573
14
+ firstops/integrations/_common.py,sha256=9gXsCoT9myPnjNPhGuRSkYHRrPNKQo4N-z1X6xZ0iQw,4615
15
+ firstops/integrations/claude.py,sha256=ECjZZ557IoWpVo1pilLbF3QCAk_OfHa4ryL2gC73eDQ,3155
16
+ firstops/integrations/langgraph.py,sha256=gHgRgr_05XmJkdoY_a8YvfviZYxS4lfJSQhmmArLg74,3395
17
+ firstops/integrations/openai_agents.py,sha256=InP2g7NZ_Eh_RDuWgBa-8ZSZo8ZA_L8bRP9SigQFHwc,3406
18
+ firstops-0.2.0.dist-info/METADATA,sha256=D7E2pjSyVykj38yyyUXXJhgnKda-OXF5bWc3bgojW-E,6269
19
+ firstops-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ firstops-0.2.0.dist-info/licenses/LICENSE,sha256=4QQ3p8KL54k-TogpPIWwCf_EvT1H8PquBqcToweyzKA,1065
21
+ firstops-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FirstOps
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.