scalebrowser 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,128 @@
1
+ """What the browser asks (interruptions), the bytes that go in and out
2
+ (artifacts), and what the licence says (account).
3
+
4
+ Interruption shapes mirror ``web-ui/src/api/types/credentials.ts``, the shared
5
+ source of contract truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal, Optional
11
+
12
+ from .models import _ReadModel, _WriteModel
13
+
14
+ #: What an operator has taken out of an agent's hands.
15
+ InterruptionLock = Literal["always_block", "always_allow", "agent_decides"]
16
+
17
+ #: One answer, in the browser's OWN vocabulary — hence ``allowOnce`` in
18
+ #: camelCase while everything else in this API is snake_case. The daemon's enum
19
+ #: is ``#[serde(rename_all = "camelCase")]``; sending ``allow_once`` is a 400.
20
+ InterruptionChoice = Literal["allow", "allowOnce", "block", "dismiss"]
21
+
22
+ #: Who wrote a standing rule. An agent must not be able to widen what the
23
+ #: operator allowed, so the two are told apart at rest.
24
+ InterruptionAuthor = Literal["operator", "agent"]
25
+
26
+
27
+ class InterruptionLockRow(_ReadModel):
28
+ kind: str
29
+ #: ``None`` for the account-wide row.
30
+ profile_id: Optional[str] = None
31
+ decision: InterruptionLock
32
+
33
+
34
+ class EffectiveInterruptionLock(_ReadModel):
35
+ """What applies right now for one kind, account-wide."""
36
+
37
+ kind: str
38
+ #: ``None`` means the agent decides.
39
+ decision: Optional[InterruptionLock] = None
40
+
41
+
42
+ class InterruptionLockView(_ReadModel):
43
+ """Everything about locks in one call, ``effective`` included.
44
+
45
+ The daemon computes ``effective`` with the same function the launch path
46
+ uses. Recomputing it yourself would be a second opinion, and code that
47
+ disagrees with the browser is worse than code that shows nothing.
48
+ """
49
+
50
+ kinds: list[str] = []
51
+ locked_by_default: list[str] = []
52
+ rows: list[InterruptionLockRow] = []
53
+ effective: list[EffectiveInterruptionLock] = []
54
+
55
+
56
+ class SetInterruptionLockBody(_WriteModel):
57
+ kind: str
58
+ profile_id: Optional[str] = None
59
+ decision: InterruptionLock
60
+
61
+
62
+ class InterruptionRuleRow(_ReadModel):
63
+ origin: str
64
+ kind: str
65
+ choice: InterruptionChoice
66
+ author: InterruptionAuthor
67
+ profile_id: Optional[str] = None
68
+
69
+
70
+ class SetInterruptionRuleBody(_WriteModel):
71
+ origin: str
72
+ kind: str
73
+ choice: InterruptionChoice
74
+ profile_id: Optional[str] = None
75
+
76
+
77
+ class ArtifactPutResult(_ReadModel):
78
+ """The reply to a file handed in for a later upload."""
79
+
80
+ artifact_id: str
81
+ size_bytes: int
82
+ name: Optional[str] = None
83
+
84
+
85
+ class ArtifactBytes(_ReadModel):
86
+ """Bytes plus what the daemon said they are."""
87
+
88
+ data: bytes
89
+ content_type: Optional[str] = None
90
+ #: Parsed out of ``Content-Disposition``, when the daemon sent one.
91
+ filename: Optional[str] = None
92
+
93
+
94
+ class Account(_ReadModel):
95
+ """Licence state, for display.
96
+
97
+ The daemon decides on every launch whether a start is allowed; this never
98
+ gets a say in it. Never gate your own code on it — a green light on a
99
+ profile the launch gate refuses is worse than no light.
100
+ """
101
+
102
+ #: ``False`` on a self-hosted build with no control plane.
103
+ licensed: bool = False
104
+ plan: Optional[str] = None
105
+ status: Optional[str] = None
106
+ #: Browsers the subscription may run AT ONCE. Replaced the profile allowance
107
+ #: on 2026-08-18: profiles are unlimited on every plan now.
108
+ concurrency: Optional[int] = None
109
+ #: Browsers running on THIS machine, not the abo-wide count.
110
+ running_local: int = 0
111
+ #: Profiles stored on THIS machine.
112
+ profiles_local: int = 0
113
+ features: list[str] = []
114
+ #: Unix seconds; when the cached entitlement stops being valid.
115
+ expires_at: Optional[int] = None
116
+ permits_launch: bool = True
117
+
118
+
119
+ class HealthStatus(_ReadModel):
120
+ status: str
121
+ service: Optional[str] = None
122
+
123
+
124
+ class ReadyStatus(_ReadModel):
125
+ status: str
126
+ #: Where the daemon looks for engines. Whether one is INSTALLED is a
127
+ #: launch-time question the preflight answers properly.
128
+ engines_dir: str = ""
@@ -0,0 +1,130 @@
1
+ """The account layer: mailboxes, passkeys and the one door a cookie value leaves
2
+ through.
3
+
4
+ Mailbox and passkey shapes mirror ``web-ui/src/api/types/{inboxes,credentials}.ts``,
5
+ the shared source of contract truth. The cookie reveal has no UI counterpart: it
6
+ is a REST-only route deliberately kept off every screen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal, Optional
12
+
13
+ from .models import _ReadModel, _WriteModel
14
+
15
+ #: Which channel a confirmation code arrives on.
16
+ InboxChannel = Literal["email", "sms"]
17
+
18
+ #: Why a credential disappeared. ``unknown`` is a reason this build cannot name,
19
+ #: and it deliberately claims nothing — reading it as ``operator`` would tell you
20
+ #: that you had deleted something you never touched.
21
+ PasskeyRetiredReason = Literal["operator", "agent", "site_revoked", "unknown"]
22
+
23
+
24
+ class Inbox(_ReadModel):
25
+ """One configured mailbox. The password is never sent back."""
26
+
27
+ id: str
28
+ name: str
29
+ host: str
30
+ port: int
31
+ #: The mailbox login. Not a secret, and it is how two rows are told apart.
32
+ user: str
33
+ #: TLS from the first byte. ``False`` is only accepted towards this machine.
34
+ tls: bool = True
35
+ folder: str = "INBOX"
36
+ #: The domain new addresses are minted under. ``None`` for a mailbox with
37
+ #: exactly one address.
38
+ address_domain: Optional[str] = None
39
+ created_at: int
40
+ #: The stored password could not be decrypted — a restored database without
41
+ #: its key. The row stays visible and deletable; every use fails closed.
42
+ credentials_unreadable: bool = False
43
+
44
+
45
+ class PutInboxBody(_WriteModel):
46
+ """Create or update a mailbox.
47
+
48
+ An omitted ``password`` means "leave the stored one unchanged", never
49
+ "delete it" — which is why it is optional on an edit and required on create.
50
+ """
51
+
52
+ name: str
53
+ host: str
54
+ port: int
55
+ user: str
56
+ password: Optional[str] = None
57
+ tls: bool = True
58
+ folder: str = "INBOX"
59
+ address_domain: Optional[str] = None
60
+
61
+
62
+ class InboxBinding(_ReadModel):
63
+ """What one profile is bound to, per channel."""
64
+
65
+ profile_id: str
66
+ channel: InboxChannel
67
+ #: The address or number this profile is known by.
68
+ address: str
69
+ #: The mailbox that answers. ``None`` means the operator's configured
70
+ #: command does, which is the only shape an SMS binding can have.
71
+ inbox_id: Optional[str] = None
72
+ created_at: int
73
+
74
+
75
+ class BindInboxBody(_WriteModel):
76
+ channel: InboxChannel
77
+ inbox_id: Optional[str] = None
78
+ #: Absent mints one under the mailbox's catch-all domain.
79
+ address: Optional[str] = None
80
+
81
+
82
+ class InboxBindings(_ReadModel):
83
+ bindings: list[InboxBinding] = []
84
+
85
+
86
+ class PasskeyRow(_ReadModel):
87
+ """One passkey of a profile — a login that needs no password.
88
+
89
+ ``credential_id`` names the row for deletion and nothing else. The private
90
+ key has no field here and no endpoint: it IS the account, and it could not
91
+ be typed anywhere useful, so returning it would be risk without a purpose.
92
+ """
93
+
94
+ credential_id: str
95
+ #: The site it belongs to, e.g. ``github.com``.
96
+ rp_id: str
97
+ user_name: Optional[str] = None
98
+ user_display: Optional[str] = None
99
+ created_at: int
100
+ last_used_at: Optional[int] = None
101
+ #: True when a stored secret cannot be decrypted — the sign-in WILL fail.
102
+ secrets_unreadable: bool = False
103
+ #: When the key stopped being usable, if it has. A retired key is never
104
+ #: loaded into the browser and never offered to an agent, but it stays
105
+ #: visible until it is cleared: an account that quietly stops working is the
106
+ #: first thing anyone asks about.
107
+ retired_at: Optional[int] = None
108
+ retired_reason: Optional[PasskeyRetiredReason] = None
109
+
110
+
111
+ class RevealedCookie(_ReadModel):
112
+ """A cookie VALUE skips both the password and the second factor beside it."""
113
+
114
+ name: str
115
+ domain: str
116
+ path: str
117
+ value: str
118
+
119
+
120
+ class RevealCookiesBody(_WriteModel):
121
+ #: The same gate a far weaker password sits behind.
122
+ vault_password: str
123
+ domain: str
124
+ #: One cookie by name; omit for every cookie on the domain.
125
+ name: Optional[str] = None
126
+
127
+
128
+ class RevealCookiesResult(_ReadModel):
129
+ domain: str
130
+ cookies: list[RevealedCookie] = []
@@ -0,0 +1,56 @@
1
+ """Agent runs and live activity (``/v1/runs``, ``/v1/activity``).
2
+
3
+ All of it is display: nothing here starts, stops or changes anything.
4
+
5
+ ``RunActor``, ``RunOutcome``, ``StepKind``, ``StepTarget``, ``RunStep`` and
6
+ ``ProfileActivity`` are NOT redeclared here — they live in :mod:`.models`
7
+ because the live event stream carries them, and the run list describes the same
8
+ rows. A second copy would let the two drift apart while both looked right.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Literal, Optional
14
+
15
+ from .models import ProfileActivity, RunActor, RunOutcome, _ReadModel
16
+
17
+ #: Who made the outcome claim. ``derived`` means nobody did.
18
+ OutcomeSource = Literal["agent", "check", "human", "derived"]
19
+
20
+ #: Whether the goal text was stated or merely describes the session.
21
+ GoalSource = Literal["agent", "derived"]
22
+
23
+
24
+ class AgentRun(_ReadModel):
25
+ """One agent run, as the activity view reads it back."""
26
+
27
+ id: str
28
+ #: Groups runs that shared one profile reservation.
29
+ session_id: str
30
+ profile_id: str
31
+ profile_name: str
32
+ actor: RunActor
33
+ #: Never empty — described when it was not declared (see :attr:`goal_source`).
34
+ goal: str
35
+ goal_source: GoalSource
36
+ #: ``reached`` and ``partial`` are CLAIMS: the daemon can tell that a run
37
+ #: ended, never that it worked, so those two only ever come from an agent, a
38
+ #: check or a person.
39
+ outcome: RunOutcome
40
+ outcome_source: Optional[OutcomeSource] = None
41
+ note: Optional[str] = None
42
+ started_at: int
43
+ ended_at: Optional[int] = None
44
+ step_count: int = 0
45
+ #: Steps that never reached the trail because the writer queue was full.
46
+ #: Zero on a healthy run, and worth reading: a trail missing entries looks
47
+ #: exactly like an agent that did less work.
48
+ steps_lost: int = 0
49
+ first_domain: Optional[str] = None
50
+
51
+
52
+ class ActivitySnapshot(_ReadModel):
53
+ """What every running profile currently shows."""
54
+
55
+ sampled_at: Optional[int] = None
56
+ profiles: list[ProfileActivity] = []
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.5
2
+ Name: scalebrowser
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for the Scalebrowser daemon — typed REST client + direct-CDP driver (nodriver-style).
5
+ Project-URL: Homepage, https://scalebrowser.net
6
+ Project-URL: Documentation, https://scalebrowser.net
7
+ Author: Scalebrowser
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent-browser,ai-agents,browser,browser-automation,cdp,mcp,scalebrowser
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pydantic>=2.7
23
+ Requires-Dist: websockets>=13
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.2; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # scalebrowser — Python SDK
31
+
32
+ Official Python SDK for the [Scalebrowser](https://scalebrowser.net) daemon: a
33
+ typed REST client **plus a direct-CDP driver** (nodriver-style) for the
34
+ self-hosted browser infrastructure that gives each AI agent its own browser.
35
+
36
+ The driver plane is **direct-CDP, not** Playwright/Puppeteer: anti-bot stacks
37
+ block the Playwright control plane regardless of how good the browser patches
38
+ are. `start_profile` returns a `cdp_ws` endpoint and this SDK speaks the Chrome
39
+ DevTools Protocol over it directly. Credentials never leave the daemon and are
40
+ never logged by the SDK.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install scalebrowser
46
+ ```
47
+
48
+ Requires Python ≥ 3.10 and depends on `httpx`, `websockets`, `pydantic` v2. The
49
+ SDK is MIT-licensed; the daemon it talks to is a separate, licensed product.
50
+
51
+ ## Quickstart (sync)
52
+
53
+ ```python
54
+ from scalebrowser import ScalebrowserClient, CreateProfileBody
55
+
56
+ sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token="…")
57
+
58
+ profile = sb.create_profile(CreateProfileBody(name="acct-01"))
59
+
60
+ # start → direct-CDP connect → navigate → humanized click → stop
61
+ with sb.launch(profile.id, headless=True) as page:
62
+ page.navigate("https://example.com")
63
+ print(page.evaluate("document.title"))
64
+ page.humanize_click(120, 240) # routed through the daemon trusted-input (G8)
65
+
66
+ sb.close()
67
+ ```
68
+
69
+ ## Quickstart (async)
70
+
71
+ ```python
72
+ import asyncio
73
+ from scalebrowser import AsyncScalebrowserClient
74
+
75
+ async def main():
76
+ async with AsyncScalebrowserClient(token="…") as sb:
77
+ started = await sb.start_profile(profile_id, headless=True) # StartProfileResult
78
+ async with await sb.connect_cdp(started, profile_id) as page:
79
+ await page.navigate("https://example.com")
80
+ title = await page.evaluate("document.title")
81
+ await page.humanize_click(120, 240)
82
+ await sb.stop_profile(profile_id)
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ ## REST surface
88
+
89
+ Every `/v1` endpoint is a typed method on the client, under the same name in
90
+ both the sync and the async client:
91
+
92
+ - **Profiles** — `list_profiles`, `get_profile`, `create_profile`,
93
+ `update_profile`, `delete_profile`, `start_profile`, `stop_profile`
94
+ - **Bulk** — `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
95
+ `bulk_assign_proxy`
96
+ - **Groups / Presets** — `list_groups`/`create_group`/`get_group`/`update_group`/`delete_group`,
97
+ `list_presets`/`create_preset`/`get_preset`/`update_preset`/`delete_preset`,
98
+ `get_persona_constraints`. A preset is `config` (what the profiles do:
99
+ `geo_mode`, `proxy_id`, …) plus `constraints` (what they are: `country`, which
100
+ pins the persona's language, timezone and voices). Both are typed
101
+ (`PresetConfig` / `PresetConstraints`) and the daemon refuses an unknown key
102
+ with a 400 — read the valid regions from `get_persona_constraints()` rather than
103
+ hardcoding them.
104
+ - **Proxies** — `list_proxies`/`create_proxy`/`get_proxy`/`update_proxy`/`delete_proxy`/`check_proxy`,
105
+ plus `check_proxy_config` (probe a config before saving it; pass `id` to reuse
106
+ an existing proxy's stored credentials)
107
+ - **Extensions** — `list_extensions`, `attach_extension`, `detach_extension`,
108
+ plus the daemon-wide library (`upload_extension`, `get_library_extension`,
109
+ `delete_library_extension`). An attached package IS loaded into the browser at
110
+ launch, under the canonical Web-Store id its own key derives
111
+ - **Credentials** — `list_credentials`, `put_credential`, `reveal_credential`
112
+ (needs the vault password), `export_credentials`, `import_credentials`
113
+ - **Cookies** — `reveal_cookies`, the one route a cookie VALUE leaves through,
114
+ behind the same vault password
115
+ - **Sessions** — `export_session`, `import_session`
116
+ - **Mailboxes** — `list_inboxes`, `create_inbox`, `update_inbox`, `delete_inbox`,
117
+ `get_inbox_bindings`, `bind_inbox`, `unbind_inbox` — where a profile's
118
+ confirmation codes arrive
119
+ - **Passkeys** — `list_passkeys`, `delete_passkey`. Metadata only: the private
120
+ key has no field and no endpoint
121
+ - **Agent runs** — `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
122
+ `get_activity`. Read-only, all of it
123
+ - **Interruptions** — `list_interruption_locks`, `set_interruption_lock`,
124
+ `list_interruption_rules`, `set_interruption_rule`,
125
+ `delete_interruption_rule` — who may answer when the browser asks something
126
+ - **Artifacts** — `put_artifact` (hand the daemon a file to upload later),
127
+ `get_artifact` (fetch a screenshot, download or saved PDF as bytes)
128
+ - **Input / Metrics / Account / Events** — `send_input`, `get_metrics`,
129
+ `get_account`, `health`, `ready`, `events()`
130
+
131
+ ```python
132
+ async for event in sb_async.events(): # SSE lifecycle stream (Bearer-authenticated)
133
+ print(event.type) # typed: profile_started / profile_crashed / …
134
+ ```
135
+
136
+ Errors map the daemon contract: `ApiError(status, code, message)` with codes
137
+ `4001–4010` (`ApiError.is_auth_error` for 401 / 4010); `NetworkError` when the
138
+ daemon is unreachable; `CdpError` for protocol-level failures.
139
+
140
+ ## Direct-CDP driver
141
+
142
+ `CdpSession` (async) / `SyncCdpSession` give you:
143
+
144
+ - `send(method, params)` — any CDP command, awaited by `id`
145
+ - `navigate(url)`, `evaluate(expr, isolated=False)` — **never** calls
146
+ `Runtime.enable` (a detection leak); isolated worlds via
147
+ `create_isolated_world()`
148
+ - `on(method, cb)` / `events()` — subscribe to CDP events
149
+ - `humanize_move/click/type/scroll` — humanized OS-level input via the daemon
150
+
151
+ ## Tests
152
+
153
+ ```bash
154
+ pip install -e ".[dev]"
155
+ pytest # unit tests (mock REST + a real fake-CDP ws server)
156
+ SCALEBROWSER_E2E=1 pytest tests/test_e2e.py # against a real daemon
157
+ ```
158
+
159
+ ## Contract assumptions
160
+
161
+ - Default base URL `http://127.0.0.1:8787`; Bearer token always.
162
+ - The trusted-input body beyond `{action, humanize}` (coordinates, `button`,
163
+ `delta_x/y`, `text`) is an SDK convention — see `cdp.py`.
164
+ - Two endpoints are optional and answer 404 on a daemon without them, which the
165
+ SDK treats as information rather than as an error: `get_metrics()` then derives
166
+ running counts from profile state, and `get_account()` returns
167
+ `licensed=False`, which is what "self-hosted, no control plane" means.
168
+ - Every method is present on BOTH clients under the same name. The sync client
169
+ is a hand-written mirror over one background event loop; there is no duplicated
170
+ endpoint logic.
@@ -0,0 +1,16 @@
1
+ scalebrowser/__init__.py,sha256=LO-KXc0vC5Nwxxv8iRg4Y3u0FHk77gweFUwSSBoRlhQ,5232
2
+ scalebrowser/_http.py,sha256=Jy8s8uVVNf1gXbu0KyL0IxFRcHPsPkGHvGHXWgWXZBc,7302
3
+ scalebrowser/_sync.py,sha256=aH5Wvo-kwmOWxi0k0a588YgTXT28TjmFt1FwscWF3bA,24565
4
+ scalebrowser/_version.py,sha256=jo6gYau27_G4AJyjriElyTAix57i6fx3UJk35fJuhF8,67
5
+ scalebrowser/cdp.py,sha256=YpRfSK2SsUZS6lud-IUO72K6VKPODBUgQTzePxGYdZs,16506
6
+ scalebrowser/client.py,sha256=lQaDPYuFqXX-risuY4pY2xLTNvyk7IZUaGWCoK7-Pug,36616
7
+ scalebrowser/errors.py,sha256=Du98Ow_Z2wdtLZyvptXSQhcPXvgnwWSIwPAWVg-UsPo,3337
8
+ scalebrowser/events.py,sha256=a65ZypdsFJxWAe06nAErPRKdGNbj8_jU8yUm-HQ2F20,1686
9
+ scalebrowser/models.py,sha256=sAbn_bZjP48C3kQuhZNrC9Hp5kv8SOYH7OyYnfLtZNU,24974
10
+ scalebrowser/models_control.py,sha256=GMZYF7tO7jYiODSYfz7wCXYIID3lplD1NzLl9KMt4Fc,3950
11
+ scalebrowser/models_identity.py,sha256=y1xtclyf2lWGKCXx4TGw1OcD1siLK69uVkhxnx-UrTY,4206
12
+ scalebrowser/models_runs.py,sha256=ul45KLZa2RctK6QQ54LzjLFtwPkfRT04bE6bUJNLlZA,2007
13
+ scalebrowser-0.2.0.dist-info/METADATA,sha256=6QD2SpinSZIwpjcbiemW_qiCmRDe0ZLzrfgxb0m1D8U,7567
14
+ scalebrowser-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ scalebrowser-0.2.0.dist-info/licenses/LICENSE,sha256=_ev9UGCBOfQuc_ESy8jXDlrXrTeUb-t3wg3we1JDTbg,1069
16
+ scalebrowser-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scalebrowser
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.