agentdisco 0.1.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.
agentdisco/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Agent Disco Python client.
2
+
3
+ Grade any public URL for AI-agent discoverability. Wraps the REST API
4
+ at https://agentdisco.io/api/v1.
5
+
6
+ Basic usage:
7
+
8
+ >>> from agentdisco import AgentDisco
9
+ >>> client = AgentDisco() # anonymous (10 scans/day/IP)
10
+ >>> scan = client.submit_scan("https://example.com")
11
+ >>> scan.id # UUID
12
+ >>> client.get_scan(scan.id).status # poll: queued/running/completed
13
+ >>> client.get_website("example.com").grade # summary: A..F
14
+
15
+ With a key (100 or 500 scans/day depending on tier):
16
+
17
+ >>> key = AgentDisco().mint_key()
18
+ >>> print(key.token) # store this — shown once
19
+ >>> authed = AgentDisco(token=key.token)
20
+ >>> authed.submit_scan("https://example.com")
21
+
22
+ See https://agentdisco.io/api/docs for the full OpenAPI spec.
23
+ """
24
+
25
+ from agentdisco.client import AgentDisco
26
+ from agentdisco.exceptions import (
27
+ AgentDiscoError,
28
+ ApiError,
29
+ InvalidUrlError,
30
+ NotFoundError,
31
+ RateLimitedError,
32
+ UnauthorizedError,
33
+ )
34
+ from agentdisco.models import ApiKey, Scan, Website
35
+
36
+ __all__ = [
37
+ "AgentDisco",
38
+ "AgentDiscoError",
39
+ "ApiError",
40
+ "ApiKey",
41
+ "InvalidUrlError",
42
+ "NotFoundError",
43
+ "RateLimitedError",
44
+ "Scan",
45
+ "UnauthorizedError",
46
+ "Website",
47
+ ]
48
+
49
+ __version__ = "0.1.0"
agentdisco/client.py ADDED
@@ -0,0 +1,219 @@
1
+ """The AgentDisco client.
2
+
3
+ Thin wrapper over the REST API. Callers construct an `AgentDisco`
4
+ instance (with optional bearer token), then call methods that map
5
+ 1:1 to endpoints and return dataclasses.
6
+
7
+ Scope is deliberately narrow — 4 endpoints today. More surface (report
8
+ diffs, check catalogue listing, scan history) can layer on without
9
+ breaking the existing shape.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from agentdisco.exceptions import (
19
+ AgentDiscoError,
20
+ ApiError,
21
+ InvalidUrlError,
22
+ NotFoundError,
23
+ RateLimitedError,
24
+ UnauthorizedError,
25
+ )
26
+ from agentdisco.models import ApiKey, Scan, Website
27
+
28
+ DEFAULT_BASE_URL = "https://agentdisco.io"
29
+ DEFAULT_TIMEOUT = 30.0
30
+ _USER_AGENT = "agentdisco-python/0.1.0"
31
+
32
+
33
+ class AgentDisco:
34
+ """Synchronous Agent Disco client.
35
+
36
+ Construct once, reuse across calls — httpx's client is
37
+ connection-pooled, so per-call construction would reopen TLS on
38
+ every request.
39
+
40
+ An async variant (`AsyncAgentDisco`) can follow when a caller needs
41
+ one; today the synchronous API is enough for CI runners, CLIs,
42
+ notebooks.
43
+
44
+ Example:
45
+
46
+ client = AgentDisco(token="ak_...")
47
+ scan = client.submit_scan("https://example.com")
48
+ while scan.status not in {"completed", "failed"}:
49
+ time.sleep(5)
50
+ scan = client.get_scan(scan.id)
51
+ print(scan.grade)
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ token: str | None = None,
57
+ *,
58
+ base_url: str = DEFAULT_BASE_URL,
59
+ timeout: float = DEFAULT_TIMEOUT,
60
+ transport: httpx.BaseTransport | None = None,
61
+ ) -> None:
62
+ """Construct a client.
63
+
64
+ `token` is an API key obtained via `mint_key()` or the web
65
+ flow at `/developers`. Absent → anonymous-tier rate limits.
66
+
67
+ `base_url` defaults to prod. Override for self-hosted
68
+ deployments or tests.
69
+
70
+ `transport` is an httpx transport escape-hatch used by the
71
+ SDK's own tests to pin a `MockTransport`. Real callers
72
+ shouldn't need it.
73
+ """
74
+ self._token = token
75
+ headers = {
76
+ "User-Agent": _USER_AGENT,
77
+ "Accept": "application/json",
78
+ }
79
+ if token is not None:
80
+ headers["Authorization"] = f"Bearer {token}"
81
+ self._http = httpx.Client(
82
+ base_url=base_url.rstrip("/"),
83
+ timeout=timeout,
84
+ headers=headers,
85
+ transport=transport,
86
+ )
87
+
88
+ def __enter__(self) -> AgentDisco:
89
+ return self
90
+
91
+ def __exit__(self, *_exc_info: object) -> None:
92
+ self.close()
93
+
94
+ def close(self) -> None:
95
+ """Close the underlying HTTP session. Safe to call twice."""
96
+ self._http.close()
97
+
98
+ # -------------------------------------------------------------
99
+ # Scans
100
+ # -------------------------------------------------------------
101
+
102
+ def submit_scan(self, url: str) -> Scan:
103
+ """Queue a scan for `url`. Returns a Scan with status=queued.
104
+
105
+ Raises `InvalidUrlError` if the URL fails server-side validation
106
+ (wrong scheme, private IP, malformed, etc.). Raises
107
+ `RateLimitedError` when the daily quota is used up — check
108
+ `.retry_after_seconds` for when to retry.
109
+ """
110
+ response = self._http.post("/api/v1/scans", json={"url": url})
111
+ payload = self._parse(response)
112
+ return Scan.from_response(payload)
113
+
114
+ def get_scan(self, scan_id: str) -> Scan:
115
+ """Fetch a scan by UUID. Raises `NotFoundError` on unknown id."""
116
+ response = self._http.get(f"/api/v1/scans/{scan_id}")
117
+ payload = self._parse(response)
118
+ return Scan.from_response(payload)
119
+
120
+ # -------------------------------------------------------------
121
+ # Websites
122
+ # -------------------------------------------------------------
123
+
124
+ def get_website(self, host: str) -> Website:
125
+ """Latest grade + scan count for a host that's been scanned.
126
+
127
+ Raises `NotFoundError` if the host has never been scanned (or
128
+ has been unlisted — the API returns 404 for both, deliberately).
129
+ """
130
+ response = self._http.get(f"/api/v1/websites/{host}")
131
+ payload = self._parse(response)
132
+ return Website.from_response(payload)
133
+
134
+ # -------------------------------------------------------------
135
+ # API keys
136
+ # -------------------------------------------------------------
137
+
138
+ def mint_key(self) -> ApiKey:
139
+ """Mint a new anonymous-tier API key.
140
+
141
+ The response contains the plaintext token exactly once — store
142
+ it immediately. The server keeps only a SHA-256 hash and
143
+ cannot reconstruct the plaintext if you lose it.
144
+
145
+ This method works without authentication (no token needed on
146
+ the calling client). To mint an authenticated-tier key (500
147
+ scans/day vs 100), sign in via the web flow at /developers.
148
+
149
+ Rate-limited at 5 keys/hour/IP; burst-hammering trips
150
+ `RateLimitedError`.
151
+ """
152
+ response = self._http.post("/api/v1/keys")
153
+ payload = self._parse(response)
154
+ return ApiKey.from_response(payload)
155
+
156
+ # -------------------------------------------------------------
157
+ # Internal
158
+ # -------------------------------------------------------------
159
+
160
+ def _parse(self, response: httpx.Response) -> dict[str, Any]:
161
+ """Extract JSON body; raise the right exception on 4xx/5xx.
162
+
163
+ Returns the raw dict — each caller wraps in its dataclass.
164
+ """
165
+ if 200 <= response.status_code < 300:
166
+ try:
167
+ body = response.json()
168
+ except ValueError as exc:
169
+ raise AgentDiscoError(
170
+ f"server returned {response.status_code} with non-JSON body",
171
+ ) from exc
172
+ if not isinstance(body, dict):
173
+ raise AgentDiscoError(
174
+ f"server returned {response.status_code} with non-object JSON",
175
+ )
176
+ return body
177
+
178
+ # Best-effort body parse so the error carries the server's
179
+ # error code + message; don't fail the wrap if the body isn't
180
+ # JSON (it usually is for /api/v1 but some 5xx paths return
181
+ # plain text).
182
+ payload: dict[str, Any] = {}
183
+ try:
184
+ parsed = response.json()
185
+ if isinstance(parsed, dict):
186
+ payload = parsed
187
+ except ValueError:
188
+ pass
189
+
190
+ message = str(payload.get("message") or payload.get("error") or response.text or "").strip()
191
+ if message == "":
192
+ message = f"HTTP {response.status_code} from Agent Disco API"
193
+ error_code = payload.get("error") if isinstance(payload.get("error"), str) else None
194
+
195
+ status = response.status_code
196
+ kwargs: dict[str, Any] = {
197
+ "status_code": status,
198
+ "error_code": error_code,
199
+ "payload": payload,
200
+ }
201
+
202
+ if status == 400 and error_code == "invalid_url":
203
+ raise InvalidUrlError(message, **kwargs)
204
+ if status == 401:
205
+ raise UnauthorizedError(message, **kwargs)
206
+ if status == 404:
207
+ raise NotFoundError(message, **kwargs)
208
+ if status == 429:
209
+ retry_after = response.headers.get("Retry-After")
210
+ retry_after_seconds = (
211
+ int(retry_after) if retry_after and retry_after.isdigit() else None
212
+ )
213
+ raise RateLimitedError(
214
+ message,
215
+ retry_after_seconds=retry_after_seconds,
216
+ **kwargs,
217
+ )
218
+
219
+ raise ApiError(message, **kwargs)
@@ -0,0 +1,80 @@
1
+ """Exception hierarchy for the Agent Disco SDK.
2
+
3
+ All SDK-raised exceptions inherit `AgentDiscoError` so a caller can
4
+ do a single broad catch. More specific subtypes (`NotFoundError`,
5
+ `RateLimitedError`, etc.) let callers react differently to recoverable
6
+ vs unrecoverable failures.
7
+
8
+ Network-layer errors (connection timeout, DNS failure) leak through
9
+ as raw `httpx` exceptions — we don't wrap them because the failure
10
+ mode is platform, not API. Application-layer errors (4xx/5xx) are
11
+ wrapped into the `ApiError` branch.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+
19
+ class AgentDiscoError(Exception):
20
+ """Root of the SDK exception hierarchy."""
21
+
22
+
23
+ class ApiError(AgentDiscoError):
24
+ """An HTTP response carried a 4xx or 5xx status.
25
+
26
+ `status_code` is the HTTP status; `error_code` is the server's
27
+ `error` field from the JSON body when present (e.g. `invalid_url`,
28
+ `not_found`); `payload` is the parsed JSON for anything the SDK
29
+ didn't model.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ message: str,
35
+ *,
36
+ status_code: int,
37
+ error_code: str | None = None,
38
+ payload: dict[str, Any] | None = None,
39
+ ) -> None:
40
+ super().__init__(message)
41
+ self.status_code = status_code
42
+ self.error_code = error_code
43
+ self.payload = payload or {}
44
+
45
+
46
+ class InvalidUrlError(ApiError):
47
+ """HTTP 400 with error=invalid_url — the URL failed server validation."""
48
+
49
+
50
+ class UnauthorizedError(ApiError):
51
+ """HTTP 401 — missing or invalid auth (ops endpoints only)."""
52
+
53
+
54
+ class NotFoundError(ApiError):
55
+ """HTTP 404 — unknown scan id or host."""
56
+
57
+
58
+ class RateLimitedError(ApiError):
59
+ """HTTP 429 — quota exceeded.
60
+
61
+ `retry_after_seconds` is parsed from the `Retry-After` response
62
+ header when present; callers can sleep that long and retry.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ message: str,
68
+ *,
69
+ status_code: int,
70
+ retry_after_seconds: int | None = None,
71
+ error_code: str | None = None,
72
+ payload: dict[str, Any] | None = None,
73
+ ) -> None:
74
+ super().__init__(
75
+ message,
76
+ status_code=status_code,
77
+ error_code=error_code,
78
+ payload=payload,
79
+ )
80
+ self.retry_after_seconds = retry_after_seconds
agentdisco/models.py ADDED
@@ -0,0 +1,94 @@
1
+ """Dataclass response models — what the REST endpoints return.
2
+
3
+ Only the load-bearing fields are modelled; the full JSON payload is
4
+ always available on `raw` for anything the SDK doesn't surface
5
+ directly. That gives us room to grow the API without breaking existing
6
+ callers who rely on specific fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Scan:
17
+ """A scan (queued, running, or completed).
18
+
19
+ `grade` and `score` are `None` while the scan is still in flight;
20
+ poll `get_scan(id)` until `status == "completed"`.
21
+ """
22
+
23
+ id: str
24
+ status: str
25
+ result_url: str
26
+ grade: str | None
27
+ score: int | None
28
+ raw: dict[str, Any]
29
+
30
+ @classmethod
31
+ def from_response(cls, payload: dict[str, Any]) -> Scan:
32
+ return cls(
33
+ id=str(payload["id"]),
34
+ status=str(payload["status"]),
35
+ result_url=str(payload.get("resultUrl") or payload.get("statusUrl") or ""),
36
+ grade=payload.get("grade"),
37
+ score=payload.get("score"),
38
+ raw=payload,
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Website:
44
+ """Summary view of a scanned host — latest grade + activity."""
45
+
46
+ host: str
47
+ latest_grade: str | None
48
+ latest_score: int | None
49
+ last_scanned_at: str | None
50
+ scan_count: int
51
+ raw: dict[str, Any]
52
+
53
+ @classmethod
54
+ def from_response(cls, payload: dict[str, Any]) -> Website:
55
+ return cls(
56
+ host=str(payload["host"]),
57
+ latest_grade=payload.get("latestGrade"),
58
+ latest_score=payload.get("latestScore"),
59
+ last_scanned_at=payload.get("lastScannedAt"),
60
+ scan_count=int(payload.get("scanCount", 0)),
61
+ raw=payload,
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ApiKey:
67
+ """A freshly-minted API key.
68
+
69
+ `token` is the full plaintext — the server returns this exactly
70
+ ONCE (at mint time) and keeps only a SHA-256 hash. Store the
71
+ token immediately; losing it means minting a fresh one.
72
+
73
+ `token_prefix` is the first 10 chars (`ak_XXXXXXX`) and is safe to
74
+ display in logs or dashboards; unlike `token`, it can't be used
75
+ to authenticate.
76
+ """
77
+
78
+ id: str
79
+ token: str
80
+ token_prefix: str
81
+ rate_limit_tier: str
82
+ created_at: str
83
+ raw: dict[str, Any]
84
+
85
+ @classmethod
86
+ def from_response(cls, payload: dict[str, Any]) -> ApiKey:
87
+ return cls(
88
+ id=str(payload["id"]),
89
+ token=str(payload["token"]),
90
+ token_prefix=str(payload["tokenPrefix"]),
91
+ rate_limit_tier=str(payload["rateLimitTier"]),
92
+ created_at=str(payload["createdAt"]),
93
+ raw=payload,
94
+ )
agentdisco/py.typed ADDED
File without changes
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentdisco
3
+ Version: 0.1.0
4
+ Summary: Python client for the Agent Disco API — grade any public URL for AI-agent discoverability.
5
+ Project-URL: Homepage, https://agentdisco.io
6
+ Project-URL: Documentation, https://agentdisco.io/api/docs
7
+ Project-URL: Repository, https://github.com/StarsolLtd/agent-disco
8
+ Author-email: Starsol Ltd <disty@agentdisco.io>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agentdisco,ai-agents,discoverability,llms-txt,openapi,scanner
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<1.0,>=0.25
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.8; extra == 'dev'
25
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
26
+ Requires-Dist: pytest>=7.4; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # agentdisco — Python client for Agent Disco
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/agentdisco.svg)](https://pypi.org/project/agentdisco/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/agentdisco.svg)](https://pypi.org/project/agentdisco/)
34
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
+ [![CI](https://github.com/agentdisco/agentdisco-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/agentdisco/agentdisco-python-sdk/actions/workflows/ci.yml)
36
+
37
+ Grade any public URL for AI-agent discoverability. Thin Python wrapper
38
+ over the REST API at <https://agentdisco.io/api/v1>.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install agentdisco
44
+ ```
45
+
46
+ Requires Python 3.9+.
47
+
48
+ ## Quick start
49
+
50
+ ### Submit a scan (anonymous, 10 scans/day/IP)
51
+
52
+ ```python
53
+ from agentdisco import AgentDisco
54
+
55
+ with AgentDisco() as client:
56
+ scan = client.submit_scan("https://example.com")
57
+ print(scan.id, scan.status)
58
+ ```
59
+
60
+ ### Poll until complete
61
+
62
+ ```python
63
+ import time
64
+
65
+ with AgentDisco() as client:
66
+ scan = client.submit_scan("https://example.com")
67
+ while scan.status not in {"completed", "failed"}:
68
+ time.sleep(5)
69
+ scan = client.get_scan(scan.id)
70
+
71
+ print(f"grade: {scan.grade} ({scan.score}/100)")
72
+ ```
73
+
74
+ ### Mint a key (raises your quota to 100 scans/day)
75
+
76
+ ```python
77
+ from agentdisco import AgentDisco
78
+
79
+ # Unauthenticated mint — no prior token needed, rate-limited at
80
+ # 5 keys/hour/IP. Token is shown ONCE; store it.
81
+ key = AgentDisco().mint_key()
82
+ print(key.token) # ak_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
83
+
84
+ authed = AgentDisco(token=key.token)
85
+ authed.submit_scan("https://your-site.example")
86
+ ```
87
+
88
+ ### Get summary for a previously-scanned host
89
+
90
+ ```python
91
+ with AgentDisco() as client:
92
+ site = client.get_website("example.com")
93
+ print(site.latest_grade, site.latest_score, site.scan_count)
94
+ ```
95
+
96
+ ## Higher rate limits
97
+
98
+ Authenticated-tier keys (500 scans/day/key) need a signed-in account.
99
+ Sign up at <https://agentdisco.io/register>, then mint via the web
100
+ form at <https://agentdisco.io/developers>.
101
+
102
+ | Tier | Rate limit | How to get |
103
+ |---|---|---|
104
+ | Anonymous (no key) | 10 scans / day / IP | default |
105
+ | Anonymous key | 100 scans / day / key | `mint_key()` above |
106
+ | Authenticated key | 500 scans / day / key | sign in, mint at `/developers` |
107
+
108
+ ## Error handling
109
+
110
+ ```python
111
+ from agentdisco import (
112
+ AgentDisco,
113
+ InvalidUrlError,
114
+ NotFoundError,
115
+ RateLimitedError,
116
+ )
117
+
118
+ try:
119
+ scan = AgentDisco().submit_scan("https://example.com")
120
+ except InvalidUrlError as e:
121
+ print(f"URL rejected: {e}")
122
+ except RateLimitedError as e:
123
+ print(f"quota exceeded; retry in {e.retry_after_seconds}s")
124
+ except NotFoundError as e:
125
+ print(f"not found: {e}")
126
+ ```
127
+
128
+ All SDK-raised exceptions inherit from `AgentDiscoError`, so a single
129
+ broad catch works too:
130
+
131
+ ```python
132
+ from agentdisco import AgentDiscoError
133
+ try:
134
+ ...
135
+ except AgentDiscoError as e:
136
+ log.warning("agentdisco failure: %s", e)
137
+ ```
138
+
139
+ Network-layer failures (connection timeout, DNS) leak through as raw
140
+ `httpx.HTTPError` — they're platform issues, not API errors.
141
+
142
+ ## Custom base URL
143
+
144
+ For self-hosted deployments or local testing:
145
+
146
+ ```python
147
+ AgentDisco(base_url="http://localhost:1977")
148
+ ```
149
+
150
+ ## Links
151
+
152
+ - API docs: <https://agentdisco.io/api/docs>
153
+ - Check catalogue: <https://agentdisco.io/checks>
154
+ - Live scanner: <https://agentdisco.io>
155
+
156
+ ## Licence
157
+
158
+ MIT. See [`LICENSE`](LICENSE). The scanner itself is operated by
159
+ **Starsol Ltd** (England, company 06002018); only this client library
160
+ is open-source. Issues + pull requests welcome.
@@ -0,0 +1,9 @@
1
+ agentdisco/__init__.py,sha256=nQYF4JzFL4FW9A8NWs-1u_2-dTzw0Dh2puYKxgkThPw,1363
2
+ agentdisco/client.py,sha256=CZIGd4l1_nUyD3AS9Dp9Vvwy-pH7N8T5OMuvGCmsL_4,7612
3
+ agentdisco/exceptions.py,sha256=hff9Mr19SXkPPp7-ALjaFYt5y-6WY0EFUPt4wyVamVQ,2298
4
+ agentdisco/models.py,sha256=wzYkQDLI7EJvKUc88fOaOvpWep-oEkE4SGpzty1JMFE,2708
5
+ agentdisco/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agentdisco-0.1.0.dist-info/METADATA,sha256=Bf0gkDEWr8ecw5Rnr36hQoP-87PbtGtjWxXmj23MUGg,4779
7
+ agentdisco-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ agentdisco-0.1.0.dist-info/licenses/LICENSE,sha256=eBT_2q1TL19ortBuaCXIzYivbs1CvQStzE5zBfITcIk,1068
9
+ agentdisco-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Starsol Ltd
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.