flagstone-sdk 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.
- flagstone_sdk/__init__.py +19 -0
- flagstone_sdk/bucketing.py +9 -0
- flagstone_sdk/client.py +138 -0
- flagstone_sdk/disk_cache.py +26 -0
- flagstone_sdk/evaluator.py +25 -0
- flagstone_sdk/poller.py +24 -0
- flagstone_sdk/py.typed +0 -0
- flagstone_sdk/stream_listener.py +46 -0
- flagstone_sdk-0.1.0.dist-info/METADATA +107 -0
- flagstone_sdk-0.1.0.dist-info/RECORD +13 -0
- flagstone_sdk-0.1.0.dist-info/WHEEL +5 -0
- flagstone_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- flagstone_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Python SDK for Flagstone feature flags.
|
|
2
|
+
|
|
3
|
+
from flagstone_sdk import FlagClient
|
|
4
|
+
|
|
5
|
+
client = FlagClient(sdk_key="...", base_url="https://flags.example.com")
|
|
6
|
+
if client.is_enabled("new-dashboard", {"id": "user-123"}):
|
|
7
|
+
...
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
from .client import FlagClient
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = version("flagstone-sdk")
|
|
16
|
+
except PackageNotFoundError: # running from a source checkout that isn't installed
|
|
17
|
+
__version__ = "0.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = ["FlagClient", "__version__"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
def bucket(flag_key: str, user_id: str) -> int:
|
|
4
|
+
raw = f"{flag_key}:{user_id}".encode()
|
|
5
|
+
digest = hashlib.md5(raw).hexdigest()
|
|
6
|
+
return int(digest[:8], 16) % 100
|
|
7
|
+
|
|
8
|
+
def in_rollout(flag_key: str, user_id: str, percentage: int) -> bool:
|
|
9
|
+
return bucket(flag_key, user_id) < percentage
|
flagstone_sdk/client.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .disk_cache import DiskCache
|
|
6
|
+
from .evaluator import evaluate
|
|
7
|
+
from .poller import Poller
|
|
8
|
+
from .stream_listener import StreamListener
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FlagClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
sdk_key: str,
|
|
15
|
+
base_url: str = "http://localhost:8000",
|
|
16
|
+
poll_interval: int = 30,
|
|
17
|
+
use_sse: bool = False,
|
|
18
|
+
cache_path: str | None = "~/.flagstone_cache.json",
|
|
19
|
+
flush_interval: int = 10,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._sdk_key = sdk_key
|
|
22
|
+
self._base_url = base_url.rstrip("/")
|
|
23
|
+
self._flags: dict[str, dict] = {}
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self._http = httpx.Client(headers={"x-sdk-key": sdk_key}, timeout=10)
|
|
26
|
+
self._event_buffer: list[dict] = []
|
|
27
|
+
self._flush_lock = threading.Lock()
|
|
28
|
+
self._flush_interval = flush_interval
|
|
29
|
+
self._cache = DiskCache(cache_path) if cache_path else None
|
|
30
|
+
try:
|
|
31
|
+
self._refresh()
|
|
32
|
+
except Exception:
|
|
33
|
+
if self._cache:
|
|
34
|
+
cached = self._cache.read()
|
|
35
|
+
if cached:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._flags = cached.get("flags", {})
|
|
38
|
+
if use_sse:
|
|
39
|
+
self._listener = StreamListener(
|
|
40
|
+
url=f"{self._base_url}/sdk/stream",
|
|
41
|
+
sdk_key=sdk_key,
|
|
42
|
+
on_update=self._on_sse_update,
|
|
43
|
+
)
|
|
44
|
+
self._listener.start()
|
|
45
|
+
else:
|
|
46
|
+
self._poller = Poller(self._refresh, poll_interval)
|
|
47
|
+
self._poller.start()
|
|
48
|
+
self._schedule_flush()
|
|
49
|
+
|
|
50
|
+
def is_enabled(self, flag_key: str, user: dict) -> bool:
|
|
51
|
+
with self._lock:
|
|
52
|
+
flag = self._flags.get(flag_key)
|
|
53
|
+
if flag is None:
|
|
54
|
+
return False
|
|
55
|
+
variation_index = evaluate(flag, user)
|
|
56
|
+
self._buffer_event("exposure", flag_key, variation_index, user)
|
|
57
|
+
return bool(flag["variations"][variation_index])
|
|
58
|
+
|
|
59
|
+
def get_variation(self, flag_key: str, user: dict) -> int:
|
|
60
|
+
with self._lock:
|
|
61
|
+
flag = self._flags.get(flag_key)
|
|
62
|
+
if flag is None:
|
|
63
|
+
return 0
|
|
64
|
+
return evaluate(flag, user)
|
|
65
|
+
|
|
66
|
+
def track_conversion(self, flag_key: str, user: dict) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
flag = self._flags.get(flag_key)
|
|
69
|
+
variation_index = evaluate(flag, user) if flag else 0
|
|
70
|
+
self._buffer_event("conversion", flag_key, variation_index, user)
|
|
71
|
+
|
|
72
|
+
def _buffer_event(self, event_type: str, flag_key: str, variation_index: int, user: dict) -> None:
|
|
73
|
+
with self._flush_lock:
|
|
74
|
+
self._event_buffer.append({
|
|
75
|
+
"flag_key": flag_key,
|
|
76
|
+
"event_type": event_type,
|
|
77
|
+
"variation_index": variation_index,
|
|
78
|
+
"user_id": user.get("id", ""),
|
|
79
|
+
"attributes": {k: v for k, v in user.items() if k != "id"},
|
|
80
|
+
})
|
|
81
|
+
if len(self._event_buffer) >= 100:
|
|
82
|
+
self._flush_now()
|
|
83
|
+
|
|
84
|
+
def _flush_now(self) -> None:
|
|
85
|
+
events, self._event_buffer = self._event_buffer, []
|
|
86
|
+
if events:
|
|
87
|
+
try:
|
|
88
|
+
self._http.post(
|
|
89
|
+
f"{self._base_url}/sdk/events",
|
|
90
|
+
json={"events": events},
|
|
91
|
+
timeout=5,
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def _schedule_flush(self) -> None:
|
|
97
|
+
if hasattr(self, "_flush_timer"):
|
|
98
|
+
self._flush_timer.cancel()
|
|
99
|
+
t = threading.Timer(self._flush_interval, self._timed_flush)
|
|
100
|
+
t.daemon = True
|
|
101
|
+
t.start()
|
|
102
|
+
self._flush_timer = t
|
|
103
|
+
|
|
104
|
+
def _timed_flush(self) -> None:
|
|
105
|
+
with self._flush_lock:
|
|
106
|
+
self._flush_now()
|
|
107
|
+
self._schedule_flush()
|
|
108
|
+
|
|
109
|
+
def _refresh(self) -> None:
|
|
110
|
+
resp = self._http.get(f"{self._base_url}/sdk/config")
|
|
111
|
+
resp.raise_for_status()
|
|
112
|
+
config = resp.json()
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._flags = config.get("flags", {})
|
|
115
|
+
if self._cache:
|
|
116
|
+
self._cache.write(config)
|
|
117
|
+
|
|
118
|
+
def _on_sse_update(self, event: dict) -> None:
|
|
119
|
+
if event.get("event") == "deleted":
|
|
120
|
+
key = event.get("key")
|
|
121
|
+
if key:
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._flags.pop(key, None)
|
|
124
|
+
else:
|
|
125
|
+
# SSE is a change-signal; fetch updated config
|
|
126
|
+
try:
|
|
127
|
+
self._refresh()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
if hasattr(self, "_flush_timer"):
|
|
133
|
+
self._flush_timer.cancel()
|
|
134
|
+
if hasattr(self, "_poller"):
|
|
135
|
+
self._poller.stop()
|
|
136
|
+
if hasattr(self, "_listener"):
|
|
137
|
+
self._listener.stop()
|
|
138
|
+
self._http.close()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DiskCache:
|
|
7
|
+
def __init__(self, path: str = "~/.flagstone_cache.json") -> None:
|
|
8
|
+
self._path = Path(path).expanduser()
|
|
9
|
+
|
|
10
|
+
def write(self, config: dict) -> None:
|
|
11
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
tmp = self._path.with_suffix(".tmp")
|
|
13
|
+
tmp.write_text(json.dumps({
|
|
14
|
+
"written_at": datetime.now(timezone.utc).isoformat(),
|
|
15
|
+
"config": config,
|
|
16
|
+
}))
|
|
17
|
+
tmp.replace(self._path)
|
|
18
|
+
|
|
19
|
+
def read(self) -> dict | None:
|
|
20
|
+
if not self._path.exists():
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(self._path.read_text())
|
|
24
|
+
return data["config"]
|
|
25
|
+
except (json.JSONDecodeError, KeyError):
|
|
26
|
+
return None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .bucketing import in_rollout
|
|
2
|
+
|
|
3
|
+
def evaluate(flag: dict, user: dict) -> int:
|
|
4
|
+
if not flag.get("enabled", True):
|
|
5
|
+
return flag.get("default_variation", 0)
|
|
6
|
+
for rule in flag.get("rules", []):
|
|
7
|
+
if _match_rule(rule, user):
|
|
8
|
+
return rule["variation"]
|
|
9
|
+
rollout = flag.get("rollout")
|
|
10
|
+
if rollout:
|
|
11
|
+
user_id = user.get("id", "")
|
|
12
|
+
if in_rollout(flag["key"], user_id, rollout["percentage"]):
|
|
13
|
+
return rollout["variation"]
|
|
14
|
+
return flag.get("default_variation", 0)
|
|
15
|
+
|
|
16
|
+
def _match_rule(rule: dict, user: dict) -> bool:
|
|
17
|
+
attr = user.get(rule["attribute"])
|
|
18
|
+
op = rule["operator"]
|
|
19
|
+
val = rule["value"]
|
|
20
|
+
if op == "equals": return attr == val
|
|
21
|
+
if op == "contains": return isinstance(attr, str) and val in attr
|
|
22
|
+
if op == "gt": return isinstance(attr, (int, float)) and attr > val
|
|
23
|
+
if op == "lt": return isinstance(attr, (int, float)) and attr < val
|
|
24
|
+
if op == "in": return attr in val
|
|
25
|
+
return False
|
flagstone_sdk/poller.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Poller:
|
|
6
|
+
def __init__(self, refresh: Callable[[], None], interval: int) -> None:
|
|
7
|
+
self._refresh = refresh
|
|
8
|
+
self._interval = interval
|
|
9
|
+
self._stop = threading.Event()
|
|
10
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
11
|
+
|
|
12
|
+
def start(self) -> None:
|
|
13
|
+
self._thread.start()
|
|
14
|
+
|
|
15
|
+
def stop(self) -> None:
|
|
16
|
+
self._stop.set()
|
|
17
|
+
self._thread.join(timeout=5)
|
|
18
|
+
|
|
19
|
+
def _run(self) -> None:
|
|
20
|
+
while not self._stop.wait(self._interval):
|
|
21
|
+
try:
|
|
22
|
+
self._refresh()
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
flagstone_sdk/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StreamListener:
|
|
10
|
+
def __init__(self, url: str, sdk_key: str, on_update: Callable[[dict], None]) -> None:
|
|
11
|
+
self._url = url
|
|
12
|
+
self._sdk_key = sdk_key
|
|
13
|
+
self._on_update = on_update
|
|
14
|
+
self._stop = threading.Event()
|
|
15
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
16
|
+
|
|
17
|
+
def start(self) -> None:
|
|
18
|
+
self._thread.start()
|
|
19
|
+
|
|
20
|
+
def stop(self) -> None:
|
|
21
|
+
self._stop.set()
|
|
22
|
+
self._thread.join(timeout=5)
|
|
23
|
+
|
|
24
|
+
def _run(self) -> None:
|
|
25
|
+
backoff = 1
|
|
26
|
+
while not self._stop.is_set():
|
|
27
|
+
try:
|
|
28
|
+
with httpx.Client(
|
|
29
|
+
headers={"x-sdk-key": self._sdk_key}, timeout=None
|
|
30
|
+
) as http:
|
|
31
|
+
with http.stream("GET", self._url) as resp:
|
|
32
|
+
backoff = 1
|
|
33
|
+
for line in resp.iter_lines():
|
|
34
|
+
if self._stop.is_set():
|
|
35
|
+
return
|
|
36
|
+
if line.startswith("data:"):
|
|
37
|
+
data = line[5:].strip()
|
|
38
|
+
if data:
|
|
39
|
+
try:
|
|
40
|
+
self._on_update(json.loads(data))
|
|
41
|
+
except json.JSONDecodeError:
|
|
42
|
+
pass
|
|
43
|
+
except Exception:
|
|
44
|
+
if not self._stop.is_set():
|
|
45
|
+
time.sleep(min(backoff, 60))
|
|
46
|
+
backoff = min(backoff * 2, 60)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flagstone-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Flagstone feature flags: local flag evaluation, percentage rollouts and A/B experiment events.
|
|
5
|
+
Author: Tejas Ashri
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: feature flags,feature toggles,a/b testing,experiments,rollout
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# flagstone-sdk
|
|
26
|
+
|
|
27
|
+
Python SDK for Flagstone feature flags.
|
|
28
|
+
|
|
29
|
+
The client downloads your project's flag configuration once and **evaluates flags locally**, in your
|
|
30
|
+
application's process. A flag check is a dictionary lookup and a few comparisons, with no network
|
|
31
|
+
call, so it is safe to use in hot paths. The client keeps its copy up to date in the background,
|
|
32
|
+
and keeps working with the last configuration it had if the Flagstone server is unreachable.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install flagstone-sdk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Python 3.10 or newer. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from flagstone_sdk import FlagClient
|
|
46
|
+
|
|
47
|
+
client = FlagClient(
|
|
48
|
+
sdk_key="fs_...", # from your project's "SDK keys" page
|
|
49
|
+
base_url="https://flags.example.com", # where your Flagstone server runs
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
user = {"id": "user-123", "plan": "pro", "country": "IN"}
|
|
53
|
+
|
|
54
|
+
if client.is_enabled("new-dashboard", user):
|
|
55
|
+
show_new_dashboard()
|
|
56
|
+
|
|
57
|
+
# Record that this user converted, for the flag's experiment results.
|
|
58
|
+
client.track_conversion("new-dashboard", user)
|
|
59
|
+
|
|
60
|
+
client.close() # on shutdown
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Create one client when your application starts and share it. Creating a client fetches the
|
|
64
|
+
configuration and starts a background refresh thread.
|
|
65
|
+
|
|
66
|
+
## Users and targeting
|
|
67
|
+
|
|
68
|
+
A user is a plain `dict`. `"id"` is required: it decides which side of a percentage rollout the
|
|
69
|
+
user falls on, and the same id always gets the same answer. Any other keys (`plan`, `country`,
|
|
70
|
+
`email`, ...) are attributes that targeting rules can match on. Rules and rollouts are configured in
|
|
71
|
+
the Flagstone admin UI.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
| Method | Returns |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `is_enabled(flag_key, user)` | `True` if the variation served to this user is truthy. Records an exposure event. Unknown flags return `False`. |
|
|
78
|
+
| `get_variation(flag_key, user)` | Index of the variation served to this user. Does not record an event. Unknown flags return `0`. |
|
|
79
|
+
| `track_conversion(flag_key, user)` | Records a conversion for the variation this user is served. |
|
|
80
|
+
| `close()` | Stops background refreshes and releases the HTTP connection. |
|
|
81
|
+
|
|
82
|
+
### Options
|
|
83
|
+
|
|
84
|
+
| Argument | Default | Meaning |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `sdk_key` | required | The project's SDK key. |
|
|
87
|
+
| `base_url` | `http://localhost:8000` | URL of your Flagstone server. |
|
|
88
|
+
| `poll_interval` | `30` | Seconds between configuration refreshes when polling. |
|
|
89
|
+
| `use_sse` | `False` | Receive changes over Server-Sent Events instead of polling. Changes arrive within a second, but the connection must be allowed through any proxies in between. |
|
|
90
|
+
| `cache_path` | `~/.flagstone_cache.json` | Where the last configuration is saved, so the client can start even if the server is down. `None` disables the cache. |
|
|
91
|
+
| `flush_interval` | `10` | Seconds between sending batched events. Events are also sent every 100 events. |
|
|
92
|
+
|
|
93
|
+
## Keep SDK keys on the server
|
|
94
|
+
|
|
95
|
+
The client downloads the full configuration of the key's project, including targeting rules and the
|
|
96
|
+
values they compare against. Use the SDK only in server-side code. Never put an SDK key in a browser,
|
|
97
|
+
mobile app or any code your users can read.
|
|
98
|
+
|
|
99
|
+
## Good to know
|
|
100
|
+
|
|
101
|
+
- Events are delivered on a best-effort basis: if sending a batch fails, that batch is dropped.
|
|
102
|
+
- `close()` does not send events that are still buffered; up to `flush_interval` seconds of events
|
|
103
|
+
can be lost at shutdown.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT. The full license text is included in the package.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
flagstone_sdk/__init__.py,sha256=wH170Q4hRznEE3i0M34cSyejo6W6Ky4IAeFaeUA3T-w,536
|
|
2
|
+
flagstone_sdk/bucketing.py,sha256=Q3JLwfTktvlEYrduJuZKd78RFTXu-uA5YYrSo8_t-rQ,307
|
|
3
|
+
flagstone_sdk/client.py,sha256=euGV7Kq71ZZDEqQzlZylBTerOO7YPbI7tvQdZKi_P5o,4659
|
|
4
|
+
flagstone_sdk/disk_cache.py,sha256=KjAAwCGkqic2H0X5paHZWT29ug8Hx9YdxVZf9RUYO6M,808
|
|
5
|
+
flagstone_sdk/evaluator.py,sha256=HdNDlcchUdJXsn6PjveNaqk7rTo1bmyWSh6OIfZDQhU,987
|
|
6
|
+
flagstone_sdk/poller.py,sha256=uZdOOrPtlfpeH-2fD2d2dxVRabDyVj8LPNnlCuOcQBU,652
|
|
7
|
+
flagstone_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
flagstone_sdk/stream_listener.py,sha256=L24it2TE3XsIsUdNfkFsjYwmZf02MLE0GdaXMGpCSPc,1596
|
|
9
|
+
flagstone_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=W5w8mnnDNnZod-UkbCMdgmuVHo-rBuFeMaLSUewjwuM,1068
|
|
10
|
+
flagstone_sdk-0.1.0.dist-info/METADATA,sha256=otM5sd-Yx_TtnnHRUkiNj7rDzMMq3bRK1sVgMN-Zdac,4403
|
|
11
|
+
flagstone_sdk-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
flagstone_sdk-0.1.0.dist-info/top_level.txt,sha256=5AEbBJdbj_V9BamhxKAdJn2PJmTSL6ahYP7ypGT_M7E,14
|
|
13
|
+
flagstone_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tejas Ashri
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flagstone_sdk
|