switchbox-flags 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.
- switchbox/__init__.py +5 -0
- switchbox/cache.py +31 -0
- switchbox/client.py +75 -0
- switchbox/evaluator.py +98 -0
- switchbox/exceptions.py +10 -0
- switchbox/models.py +48 -0
- switchbox/sync.py +76 -0
- switchbox_flags-0.1.0.dist-info/METADATA +90 -0
- switchbox_flags-0.1.0.dist-info/RECORD +11 -0
- switchbox_flags-0.1.0.dist-info/WHEEL +4 -0
- switchbox_flags-0.1.0.dist-info/licenses/LICENSE +21 -0
switchbox/__init__.py
ADDED
switchbox/cache.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from switchbox.models import Flag, FlagConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FlagCache:
|
|
7
|
+
"""Thread-safe in-memory store for flag configs."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._config: FlagConfig | None = None
|
|
11
|
+
self._lock = threading.Lock()
|
|
12
|
+
|
|
13
|
+
def get_config(self) -> FlagConfig | None:
|
|
14
|
+
with self._lock:
|
|
15
|
+
return self._config
|
|
16
|
+
|
|
17
|
+
def set_config(self, config: FlagConfig) -> None:
|
|
18
|
+
with self._lock:
|
|
19
|
+
self._config = config
|
|
20
|
+
|
|
21
|
+
def get_flag(self, key: str) -> Flag | None:
|
|
22
|
+
with self._lock:
|
|
23
|
+
if self._config is None:
|
|
24
|
+
return None
|
|
25
|
+
return self._config.flags.get(key)
|
|
26
|
+
|
|
27
|
+
def get_version(self) -> str | None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
if self._config is None:
|
|
30
|
+
return None
|
|
31
|
+
return self._config.version
|
switchbox/client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
|
|
3
|
+
from switchbox.cache import FlagCache
|
|
4
|
+
from switchbox.evaluator import evaluate
|
|
5
|
+
from switchbox.sync import SyncWorker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Client:
|
|
9
|
+
"""Switchbox feature flag client.
|
|
10
|
+
|
|
11
|
+
Fetches flag configs from a CDN and evaluates them locally.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
client = Client(cdn_url="https://cdn.example.com/proj/production/flags.json")
|
|
16
|
+
if client.enabled("new_feature", user={"user_id": "42"}):
|
|
17
|
+
...
|
|
18
|
+
client.close()
|
|
19
|
+
|
|
20
|
+
Or as a context manager::
|
|
21
|
+
|
|
22
|
+
with Client(cdn_url="...") as client:
|
|
23
|
+
if client.enabled("new_feature"):
|
|
24
|
+
...
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
cdn_url: str,
|
|
30
|
+
poll_interval: int = 30,
|
|
31
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._cache = FlagCache()
|
|
34
|
+
self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error)
|
|
35
|
+
self._sync.start()
|
|
36
|
+
|
|
37
|
+
def enabled(self, flag_key: str, user: dict | None = None) -> bool:
|
|
38
|
+
"""Check if a boolean flag is enabled for a user.
|
|
39
|
+
|
|
40
|
+
Returns False if the flag doesn't exist (safe default).
|
|
41
|
+
"""
|
|
42
|
+
flag = self._cache.get_flag(flag_key)
|
|
43
|
+
if flag is None:
|
|
44
|
+
return False
|
|
45
|
+
result = evaluate(flag, user)
|
|
46
|
+
return bool(result)
|
|
47
|
+
|
|
48
|
+
def get_value(
|
|
49
|
+
self, flag_key: str, user: dict | None = None, default: Any = None
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Get the resolved value of any flag type.
|
|
52
|
+
|
|
53
|
+
Returns *default* if the flag doesn't exist.
|
|
54
|
+
"""
|
|
55
|
+
flag = self._cache.get_flag(flag_key)
|
|
56
|
+
if flag is None:
|
|
57
|
+
return default
|
|
58
|
+
return evaluate(flag, user)
|
|
59
|
+
|
|
60
|
+
def get_all_flags(self, user: dict | None = None) -> dict[str, Any]:
|
|
61
|
+
"""Get all flag values resolved for a user."""
|
|
62
|
+
config = self._cache.get_config()
|
|
63
|
+
if config is None:
|
|
64
|
+
return {}
|
|
65
|
+
return {key: evaluate(flag, user) for key, flag in config.flags.items()}
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
"""Stop the background sync. Call on shutdown."""
|
|
69
|
+
self._sync.stop()
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> Client:
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *args: object) -> None:
|
|
75
|
+
self.close()
|
switchbox/evaluator.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pure evaluation engine for Switchbox feature flags.
|
|
2
|
+
|
|
3
|
+
Zero external dependencies — only Python stdlib.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from switchbox.models import Flag, Rule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int | Any:
|
|
13
|
+
"""Evaluate a flag for a given user context.
|
|
14
|
+
|
|
15
|
+
Returns the flag's resolved value.
|
|
16
|
+
|
|
17
|
+
Evaluation order:
|
|
18
|
+
1. Flag disabled → default_value
|
|
19
|
+
2. No user context → enabled value if rollout == 100, else default_value
|
|
20
|
+
3. Rules match (OR logic) → enabled value
|
|
21
|
+
4. Rollout percentage check → enabled value or default_value
|
|
22
|
+
5. Nothing matched → default_value
|
|
23
|
+
"""
|
|
24
|
+
# 1. Disabled flag always returns default
|
|
25
|
+
if not flag.enabled:
|
|
26
|
+
return flag.default_value
|
|
27
|
+
|
|
28
|
+
# 2. No user context
|
|
29
|
+
if not user_context:
|
|
30
|
+
if flag.rollout_pct == 100:
|
|
31
|
+
return _enabled_value(flag)
|
|
32
|
+
return flag.default_value
|
|
33
|
+
|
|
34
|
+
# 3. Check rules (OR logic — any match wins)
|
|
35
|
+
if flag.rules:
|
|
36
|
+
for rule in flag.rules:
|
|
37
|
+
if _match_rule(rule, user_context):
|
|
38
|
+
return _enabled_value(flag)
|
|
39
|
+
|
|
40
|
+
# 4. Rollout percentage
|
|
41
|
+
user_id = user_context.get("user_id") or user_context.get("id")
|
|
42
|
+
if user_id is not None:
|
|
43
|
+
if _check_rollout(str(user_id), flag.key, flag.rollout_pct):
|
|
44
|
+
return _enabled_value(flag)
|
|
45
|
+
else:
|
|
46
|
+
# No user ID for hashing — can only serve 100% rollouts
|
|
47
|
+
if flag.rollout_pct == 100:
|
|
48
|
+
return _enabled_value(flag)
|
|
49
|
+
return flag.default_value
|
|
50
|
+
|
|
51
|
+
# 5. Nothing matched
|
|
52
|
+
return flag.default_value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _enabled_value(flag: Flag) -> bool | str | int | Any:
|
|
56
|
+
"""Return the appropriate 'enabled' value based on flag type."""
|
|
57
|
+
if flag.flag_type == "boolean":
|
|
58
|
+
return True
|
|
59
|
+
return flag.default_value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _match_rule(rule: Rule, user_context: dict) -> bool:
|
|
63
|
+
"""Check if a single rule matches the user context."""
|
|
64
|
+
if rule.attribute not in user_context:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
context_value = user_context[rule.attribute]
|
|
68
|
+
|
|
69
|
+
if rule.operator == "equals":
|
|
70
|
+
return str(context_value) == str(rule.value)
|
|
71
|
+
elif rule.operator == "not_equals":
|
|
72
|
+
return str(context_value) != str(rule.value)
|
|
73
|
+
elif rule.operator == "contains":
|
|
74
|
+
return str(rule.value) in str(context_value)
|
|
75
|
+
elif rule.operator == "ends_with":
|
|
76
|
+
return str(context_value).endswith(str(rule.value))
|
|
77
|
+
elif rule.operator == "in_list":
|
|
78
|
+
return str(context_value) in rule.value
|
|
79
|
+
elif rule.operator == "gt":
|
|
80
|
+
try:
|
|
81
|
+
return float(context_value) > float(rule.value)
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
return False
|
|
84
|
+
elif rule.operator == "lt":
|
|
85
|
+
try:
|
|
86
|
+
return float(context_value) < float(rule.value)
|
|
87
|
+
except (ValueError, TypeError):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_rollout(user_id: str, flag_key: str, rollout_pct: int) -> bool:
|
|
94
|
+
"""Deterministic percentage rollout using consistent hashing."""
|
|
95
|
+
hash_input = f"{user_id}:{flag_key}"
|
|
96
|
+
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
|
|
97
|
+
bucket = hash_value % 100
|
|
98
|
+
return bucket < rollout_pct
|
switchbox/exceptions.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class SwitchboxError(Exception):
|
|
2
|
+
"""Base exception for Switchbox SDK."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigFetchError(SwitchboxError):
|
|
6
|
+
"""Raised when fetching flag config from CDN fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EvaluationError(SwitchboxError):
|
|
10
|
+
"""Raised when flag evaluation encounters an error."""
|
switchbox/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Rule:
|
|
7
|
+
attribute: str
|
|
8
|
+
operator: str # equals | not_equals | contains | ends_with | in_list | gt | lt
|
|
9
|
+
value: Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Flag:
|
|
14
|
+
key: str
|
|
15
|
+
enabled: bool
|
|
16
|
+
rollout_pct: int
|
|
17
|
+
flag_type: str # boolean | string | number | json
|
|
18
|
+
default_value: Any
|
|
19
|
+
rules: list[Rule] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class FlagConfig:
|
|
24
|
+
version: str # ISO timestamp
|
|
25
|
+
flags: dict[str, Flag] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict) -> FlagConfig:
|
|
29
|
+
"""Parse the CDN JSON into a FlagConfig object."""
|
|
30
|
+
flags = {}
|
|
31
|
+
for key, flag_data in data.get("flags", {}).items():
|
|
32
|
+
rules = [
|
|
33
|
+
Rule(
|
|
34
|
+
attribute=r["attribute"],
|
|
35
|
+
operator=r["operator"],
|
|
36
|
+
value=r["value"],
|
|
37
|
+
)
|
|
38
|
+
for r in flag_data.get("rules", [])
|
|
39
|
+
]
|
|
40
|
+
flags[key] = Flag(
|
|
41
|
+
key=key,
|
|
42
|
+
enabled=flag_data["enabled"],
|
|
43
|
+
rollout_pct=flag_data.get("rollout_pct", 0),
|
|
44
|
+
flag_type=flag_data.get("flag_type", "boolean"),
|
|
45
|
+
default_value=flag_data.get("default_value"),
|
|
46
|
+
rules=rules,
|
|
47
|
+
)
|
|
48
|
+
return cls(version=data.get("version", ""), flags=flags)
|
switchbox/sync.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import urllib.request
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from switchbox.cache import FlagCache
|
|
8
|
+
from switchbox.exceptions import ConfigFetchError
|
|
9
|
+
from switchbox.models import FlagConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("switchbox")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncWorker:
|
|
15
|
+
"""Background thread that polls the CDN for updated flag configs."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
cdn_url: str,
|
|
20
|
+
cache: FlagCache,
|
|
21
|
+
interval: int = 30,
|
|
22
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._cdn_url = cdn_url
|
|
25
|
+
self._cache = cache
|
|
26
|
+
self._interval = interval
|
|
27
|
+
self._on_error = on_error
|
|
28
|
+
self._stop_event = threading.Event()
|
|
29
|
+
self._thread: threading.Thread | None = None
|
|
30
|
+
|
|
31
|
+
def start(self) -> None:
|
|
32
|
+
"""Fetch configs synchronously first, then start background polling."""
|
|
33
|
+
# Initial synchronous fetch — block until we have configs
|
|
34
|
+
self._poll()
|
|
35
|
+
|
|
36
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
37
|
+
self._thread.start()
|
|
38
|
+
|
|
39
|
+
def stop(self) -> None:
|
|
40
|
+
"""Stop the background polling thread gracefully."""
|
|
41
|
+
self._stop_event.set()
|
|
42
|
+
if self._thread is not None:
|
|
43
|
+
self._thread.join(timeout=5)
|
|
44
|
+
|
|
45
|
+
def _run(self) -> None:
|
|
46
|
+
"""Main loop for the background thread."""
|
|
47
|
+
while not self._stop_event.wait(timeout=self._interval):
|
|
48
|
+
self._poll()
|
|
49
|
+
|
|
50
|
+
def _poll(self) -> None:
|
|
51
|
+
"""Fetch config from CDN, parse, and update cache if changed."""
|
|
52
|
+
try:
|
|
53
|
+
req = urllib.request.Request(
|
|
54
|
+
self._cdn_url,
|
|
55
|
+
headers={"User-Agent": "switchbox-python/0.1.0"},
|
|
56
|
+
)
|
|
57
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
58
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
59
|
+
|
|
60
|
+
# Skip parsing if version hasn't changed
|
|
61
|
+
new_version = data.get("version", "")
|
|
62
|
+
current_version = self._cache.get_version()
|
|
63
|
+
if current_version and new_version == current_version:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
config = FlagConfig.from_dict(data)
|
|
67
|
+
self._cache.set_config(config)
|
|
68
|
+
logger.debug("Updated flag config to version %s", config.version)
|
|
69
|
+
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.warning("Failed to fetch flag config from %s: %s", self._cdn_url, exc)
|
|
72
|
+
if self._on_error is not None:
|
|
73
|
+
try:
|
|
74
|
+
self._on_error(ConfigFetchError(str(exc)))
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: switchbox-flags
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Feature flag SDK with zero dependencies
|
|
5
|
+
Project-URL: Homepage, https://github.com/ignat14/switchbox-sdk-python
|
|
6
|
+
Project-URL: Repository, https://github.com/ignat14/switchbox-sdk-python
|
|
7
|
+
Project-URL: Issues, https://github.com/ignat14/switchbox-sdk-python/issues
|
|
8
|
+
Author: Switchbox
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: feature-flags,feature-toggles,sdk
|
|
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.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Switchbox
|
|
25
|
+
|
|
26
|
+
Feature flag SDK for Python. Zero dependencies. Reads configs from a CDN.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install switchbox-flags
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from switchbox import Client
|
|
38
|
+
|
|
39
|
+
client = Client(cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json")
|
|
40
|
+
|
|
41
|
+
# Boolean flag
|
|
42
|
+
if client.enabled("new_checkout", user={"user_id": "42", "email": "a@b.com"}):
|
|
43
|
+
show_new_checkout()
|
|
44
|
+
|
|
45
|
+
# String flag
|
|
46
|
+
version = client.get_value("search_version", user={"user_id": "42"}, default="v1")
|
|
47
|
+
|
|
48
|
+
# All flags at once
|
|
49
|
+
flags = client.get_all_flags(user={"user_id": "42"})
|
|
50
|
+
|
|
51
|
+
# Cleanup
|
|
52
|
+
client.close()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or use as a context manager:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with Client(cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json") as client:
|
|
59
|
+
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
60
|
+
show_new_checkout()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
- Fetches flag configs from CDN (static JSON, no server in the loop)
|
|
66
|
+
- Evaluates rules locally (sub-millisecond)
|
|
67
|
+
- Polls for updates every 30 seconds (configurable)
|
|
68
|
+
- Works offline — keeps using cached configs if CDN is unreachable
|
|
69
|
+
- Zero runtime dependencies — only Python stdlib
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client = Client(
|
|
75
|
+
cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json",
|
|
76
|
+
poll_interval=60, # poll every 60 seconds (default: 30)
|
|
77
|
+
on_error=lambda e: print(e), # optional error callback
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Evaluation logic
|
|
82
|
+
|
|
83
|
+
1. **Disabled flag** — returns `default_value`
|
|
84
|
+
2. **Rules** — if any rule matches the user context (OR logic), the flag is on
|
|
85
|
+
3. **Rollout** — deterministic percentage based on `sha256(user_id:flag_key)`
|
|
86
|
+
4. **Fallback** — returns `default_value`
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
switchbox/__init__.py,sha256=euy9_hMMg70MENnhWb5cKNyusiXCGZmc2d08QsMjSAs,146
|
|
2
|
+
switchbox/cache.py,sha256=WmeKkQSiVrOtZh_psou6qXibSL82nQJw7jxNfqTXT5E,835
|
|
3
|
+
switchbox/client.py,sha256=GISyZaYOPNJkxXKVkapn9AwZz0bTMfvyFLvqhebatz0,2202
|
|
4
|
+
switchbox/evaluator.py,sha256=Xuo5ybTfHvytouuIIoor9Q6HVPGbCFIhknaFXnq663k,3194
|
|
5
|
+
switchbox/exceptions.py,sha256=Vv5YMiqh9gdEztXX9gGvvl721xV7f3Gzk2TcHS8WEwY,278
|
|
6
|
+
switchbox/models.py,sha256=xdKvg6fOrtwdh45nGsxm8w9M81W8W4A9klcYeKXNgjM,1397
|
|
7
|
+
switchbox/sync.py,sha256=Vzx8YdMydjDUujL-Gw7--xHOu0p25FqFsh3MshgP6b4,2565
|
|
8
|
+
switchbox_flags-0.1.0.dist-info/METADATA,sha256=913axQyjtRBPJS-0fi1KOod4eobZh19gxa39OH77vmQ,2543
|
|
9
|
+
switchbox_flags-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
switchbox_flags-0.1.0.dist-info/licenses/LICENSE,sha256=wcGGHspwJcuwpobuEkium65D2JZax2cUwEpT1V7nPCs,1066
|
|
11
|
+
switchbox_flags-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Switchbox
|
|
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.
|