clapback-client 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.
@@ -0,0 +1,46 @@
1
+ """`clapback-client` — the contract a tool follows to take part in the commons.
2
+
3
+ `ADR-0011` point 2. A tool that contributes has four obligations, each decided in
4
+ an earlier record, and this package is those obligations as code:
5
+
6
+ 1. Fingerprint the audio and hash it canonically — `hash_fingerprint`,
7
+ `fingerprint_file`. `ADR-0010`.
8
+ 2. Produce the vector through a declared pipeline — the caller's job; this
9
+ package never embeds. `clapback-embed` is the reference pipeline, and a tool
10
+ with its own declares its own identity.
11
+ 3. Look up before contributing — `Corpus.has`, `Corpus.lookup`. `ADR-0008`.
12
+ 4. Send `client_id` and `pipeline_version` — `Corpus.contribute`, `identity`.
13
+ `ADR-0004`, `ADR-0006`.
14
+
15
+ Nothing beyond the standard library, on purpose: a tool that has an embedder and
16
+ only wants to contribute must not have to install ONNX Runtime to do so.
17
+
18
+ from clapback_client import Corpus, fingerprint_file, hash_fingerprint
19
+
20
+ key = hash_fingerprint(fingerprint_file(path))
21
+ corpus = Corpus()
22
+ row = corpus.lookup(key, pipeline_version)
23
+ if row is None:
24
+ corpus.contribute(
25
+ fingerprint_hash=key,
26
+ embedding=vector,
27
+ pipeline_version=pipeline_version,
28
+ client_id=client_id,
29
+ )
30
+ """
31
+
32
+ from .corpus import DEFAULT_BASE_URL, Corpus, CorpusError
33
+ from .fingerprint import FingerprintUnavailable, canonical, fingerprint_file, hash_fingerprint
34
+ from .identity import ensure_client_id, mint_client_id
35
+
36
+ __all__ = [
37
+ "DEFAULT_BASE_URL",
38
+ "Corpus",
39
+ "CorpusError",
40
+ "FingerprintUnavailable",
41
+ "canonical",
42
+ "ensure_client_id",
43
+ "fingerprint_file",
44
+ "hash_fingerprint",
45
+ "mint_client_id",
46
+ ]
@@ -0,0 +1,163 @@
1
+ """Talking to the commons over HTTP, and only over HTTP.
2
+
3
+ This is the client half of the contract `ADR-0011` publishes: look up before you
4
+ contribute, send what the records require, and back off when told to. A tool that
5
+ imports this and `fingerprint.py` has everything it needs to be a contributor,
6
+ and nothing it does not.
7
+
8
+ `ADR-0005` point 12: the API is the only way in. Every guarantee the corpus makes
9
+ — revocation, quotas, the row ceiling, agreement recording — is code on the write
10
+ path, so a client that reached the database directly would be a second write path
11
+ with none of them.
12
+
13
+ `urllib` rather than `httpx` or `requests` on purpose. This package's argument is
14
+ that it is small enough to install next to anything; two calls against a JSON API
15
+ do not justify a dependency, and the one place that matters — retrying a 429 — is
16
+ a loop either way.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+
26
+ DEFAULT_BASE_URL = "https://clapback.seethroughlab.com"
27
+
28
+ #: The server rate-limits contributions. Backing off politely is the difference
29
+ #: between a slow client and a client the operator has to block, and a long run
30
+ #: will meet this: Familiar's backfill of 26,431 tracks took roughly 80 minutes
31
+ #: of paced lookups.
32
+ _RETRY_DELAYS = (2.0, 5.0, 15.0)
33
+
34
+
35
+ class CorpusError(RuntimeError):
36
+ """The corpus could not be reached, or refused something it should not have."""
37
+
38
+
39
+ class Corpus:
40
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0) -> None:
41
+ self.base_url = base_url.rstrip("/")
42
+ self.timeout = timeout
43
+
44
+ def _request(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict | None]:
45
+ data = json.dumps(body).encode() if body is not None else None
46
+ req = urllib.request.Request(
47
+ f"{self.base_url}{path}",
48
+ data=data,
49
+ method=method,
50
+ headers={
51
+ "Content-Type": "application/json",
52
+ "Accept": "application/json",
53
+ # Say who is calling. Not identity — `ADR-0004` point 1 keeps that
54
+ # to `client_id` in the body — but an operator reading logs should
55
+ # be able to tell this tool from a browser.
56
+ "User-Agent": "clapback-client",
57
+ },
58
+ )
59
+ try:
60
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
61
+ raw = resp.read()
62
+ return resp.status, (json.loads(raw) if raw else None)
63
+ except urllib.error.HTTPError as exc:
64
+ raw = exc.read()
65
+ try:
66
+ payload = json.loads(raw) if raw else None
67
+ except json.JSONDecodeError:
68
+ payload = None
69
+ return exc.code, payload
70
+ except urllib.error.URLError as exc:
71
+ raise CorpusError(f"{self.base_url} is unreachable: {exc.reason}") from exc
72
+ except TimeoutError as exc:
73
+ raise CorpusError(f"{self.base_url} timed out after {self.timeout}s") from exc
74
+
75
+ def health(self) -> bool:
76
+ status, _ = self._request("GET", "/health")
77
+ return status == 200
78
+
79
+ def lookup(self, fingerprint_hash: str, pipeline_version: str) -> dict | None:
80
+ """The corpus's row for this recording from this pipeline, or None.
81
+
82
+ The row carries `embedding` (512 floats), `contributor_count`, and the
83
+ pipeline it was produced by. A tool that gets a row back here does not
84
+ need to run the model: that is the whole exchange a plug-in makes, and
85
+ on a Raspberry Pi it is minutes per track.
86
+
87
+ Only a row from the *same* pipeline is returned. Two vectors are comparable
88
+ exactly when their pipeline identities match (`ADR-0006`), so a vector from
89
+ another pipeline would be a wrong answer wearing the right shape.
90
+ """
91
+ # The pipeline identity is `+`-joined, and `+` means a space in a query
92
+ # string. `ADR-0006`'s Implementation block records what an unescaped one
93
+ # costs: a 404 that looks exactly like the recording being absent.
94
+ from urllib.parse import quote
95
+
96
+ status, payload = self._request(
97
+ "GET",
98
+ f"/v1/embeddings/{fingerprint_hash}?pipeline_version={quote(pipeline_version, safe='')}",
99
+ )
100
+ if status == 200:
101
+ return payload
102
+ if status == 404:
103
+ return None
104
+ raise CorpusError(f"lookup returned {status}")
105
+
106
+ def has(self, fingerprint_hash: str, pipeline_version: str) -> bool:
107
+ """Whether the corpus already holds this recording from this pipeline.
108
+
109
+ **Asked before every contribution, and that is not an optimisation.** A
110
+ repeat POST of a vector that is already there increments
111
+ `contributor_count` and records a `submission_agreement` row, so a client
112
+ that re-sent its library would manufacture evidence of one installation
113
+ independently agreeing with itself — which is precisely the measurement
114
+ `ADR-0008` is built on. Two clients have learned this now; it is why the
115
+ contract publishes the check rather than trusting each tool to write it.
116
+ """
117
+ return self.lookup(fingerprint_hash, pipeline_version) is not None
118
+
119
+ def contribute(
120
+ self,
121
+ *,
122
+ fingerprint_hash: str,
123
+ embedding: list[float],
124
+ pipeline_version: str,
125
+ client_id: str,
126
+ clap_model_version: str | None = None,
127
+ analysis_version: int = 1,
128
+ ) -> str:
129
+ """POST one embedding. Returns a short word describing what happened.
130
+
131
+ `pipeline_version` and `client_id` are what the records require of a
132
+ contribution (`ADR-0006` point 4, `ADR-0004` point 1) and have no
133
+ defaults. The other two are recorded columns the key no longer includes:
134
+ `clap_model_version` defaults to the first component of the pipeline
135
+ identity, which is the checkpoint, so the two cannot disagree about one
136
+ fact; `analysis_version` is the caller's own counter and starts at 1.
137
+ """
138
+ body = {
139
+ "fingerprint_hash": fingerprint_hash,
140
+ "embedding": embedding,
141
+ "pipeline_version": pipeline_version,
142
+ "clap_model_version": clap_model_version or pipeline_version.split("+")[0],
143
+ "analysis_version": analysis_version,
144
+ "client_id": client_id,
145
+ }
146
+ for attempt, delay in enumerate((*_RETRY_DELAYS, None)):
147
+ status, payload = self._request("POST", "/v1/embeddings", body)
148
+ if status in (200, 201):
149
+ return "contributed"
150
+ if status == 429:
151
+ if delay is None:
152
+ break
153
+ time.sleep(delay)
154
+ continue
155
+ if status == 422:
156
+ detail = (payload or {}).get("detail")
157
+ raise CorpusError(f"the corpus refused the submission as malformed: {detail}")
158
+ if status == 507 or (status == 403 and "ceiling" in str(payload).lower()):
159
+ # `ADR-0004` point 9's row ceiling. A refusal here is the corpus
160
+ # working, not failing — stop rather than hammering it.
161
+ raise CorpusError("the corpus is full and is refusing writes (ADR-0004 point 9)")
162
+ raise CorpusError(f"contribute returned {status}: {payload}")
163
+ raise CorpusError("rate limited repeatedly; try again later")
@@ -0,0 +1,127 @@
1
+ """The corpus key's other half: `ADR-0010`.
2
+
3
+ `fingerprint_hash` is SHA256 of the AcoustID fingerprint **as chromaprint returned
4
+ it** — the base64 ASCII string — and of nothing else. The rule exists because it
5
+ was broken: Familiar hashed whatever its column happened to hold, and that column
6
+ held the same fingerprint in two encodings (14,284 hex-escaped against 11,364
7
+ raw, measured 2026-09-10), both of which are live keys in the corpus today.
8
+
9
+ So the rule is "hash what you computed, not what you stored", and this module is
10
+ where this tool computes it. Nothing here reads a database, which is the point:
11
+ the value goes from chromaprint into `sha256` without passing through storage, so
12
+ there is no encoding for storage to apply.
13
+
14
+ `canonical()` exists anyway, for the case where a fingerprint *has* been through
15
+ something. It is the one place that knows what a re-encoding looks like.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+
25
+
26
+ class FingerprintUnavailable(RuntimeError):
27
+ """chromaprint is missing or refused the file.
28
+
29
+ `ADR-0009` point 5: the local half of this tool — index, search, duplicates —
30
+ works without chromaprint and must never be made to depend on it. Only talking
31
+ to the corpus needs a fingerprint, so this is raised there and nowhere else.
32
+ """
33
+
34
+
35
+ def canonical(fingerprint: str | bytes) -> bytes:
36
+ """The bytes to hash, whatever shape the fingerprint arrives in.
37
+
38
+ A fingerprint from chromaprint is already canonical and passes through. The
39
+ one transformation undone here is Postgres's hex output format — a `text`
40
+ column that once held `bytea` renders as `\\x` followed by hex, and hashing
41
+ that string keys the row to a fact about somebody's schema history rather
42
+ than about the recording. `ADR-0010` point 2.
43
+
44
+ The check is deliberately narrow. `\\x` plus an even number of hex digits
45
+ that decode to printable ASCII is not something a chromaprint fingerprint can
46
+ be — its alphabet is base64 and it never begins with a backslash — so this
47
+ cannot misfire on a real fingerprint, and anything it does not recognise is
48
+ left alone rather than guessed at.
49
+ """
50
+ if isinstance(fingerprint, bytes):
51
+ raw = fingerprint
52
+ else:
53
+ raw = fingerprint.encode()
54
+
55
+ if raw.startswith(b"\\x") and len(raw) % 2 == 0:
56
+ body = raw[2:]
57
+ try:
58
+ decoded = bytes.fromhex(body.decode("ascii"))
59
+ except (ValueError, UnicodeDecodeError):
60
+ return raw
61
+ # Only accept the decode if it produced something that looks like a
62
+ # fingerprint rather than arbitrary bytes that happened to be valid hex.
63
+ if decoded and all(32 <= b < 127 for b in decoded):
64
+ return decoded
65
+ return raw
66
+
67
+
68
+ def hash_fingerprint(fingerprint: str | bytes) -> str:
69
+ """SHA256 of the canonical fingerprint, hex-digested — the corpus key.
70
+
71
+ One-way on purpose: contributing says "I have this recording" without saying
72
+ which recording it is, which is what lets somebody contribute from a library
73
+ they would rather not publish. It is also why no server-side migration could
74
+ ever repair a bad key — the corpus never learns the fingerprint, so only a
75
+ client holding it can compute a different hash for the same recording.
76
+ """
77
+ return hashlib.sha256(canonical(fingerprint)).hexdigest()
78
+
79
+
80
+ def fingerprint_file(path: str) -> str:
81
+ """The AcoustID fingerprint of one file, exactly as chromaprint gives it.
82
+
83
+ Run out of process for the reason `ADR-0009` point 5 gives: chromaprint is a
84
+ C library that crashes rather than raises on some malformed inputs, and a
85
+ segfault in a library of 20,000 files must cost one file rather than the run.
86
+ A crashed child is a non-zero exit code here.
87
+ """
88
+ if shutil.which("fpcalc") is None:
89
+ try:
90
+ import acoustid # noqa: F401
91
+ except ImportError as exc:
92
+ raise FingerprintUnavailable(
93
+ "chromaprint is not installed, so this tool cannot talk to the corpus. "
94
+ "Install it (`brew install chromaprint`, `apt install libchromaprint-tools`) "
95
+ "and `pip install pyacoustid`. Indexing, search and duplicates do not need it."
96
+ ) from exc
97
+
98
+ try:
99
+ result = subprocess.run(
100
+ [
101
+ sys.executable,
102
+ "-c",
103
+ (
104
+ "import acoustid, json, sys; "
105
+ "d, f = acoustid.fingerprint_file(sys.argv[1]); "
106
+ "print(json.dumps(f.decode() if isinstance(f, bytes) else f))"
107
+ ),
108
+ path,
109
+ ],
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=60,
113
+ check=False,
114
+ )
115
+ except subprocess.TimeoutExpired as exc:
116
+ raise FingerprintUnavailable(f"fingerprinting timed out: {path}") from exc
117
+
118
+ if result.returncode != 0 or not result.stdout.strip():
119
+ detail = (result.stderr or "").strip().splitlines()
120
+ raise FingerprintUnavailable(detail[-1] if detail else f"exit {result.returncode}")
121
+
122
+ import json
123
+
124
+ value = json.loads(result.stdout.strip())
125
+ if not isinstance(value, str) or not value:
126
+ raise FingerprintUnavailable(f"chromaprint returned nothing usable for {path}")
127
+ return value
@@ -0,0 +1,48 @@
1
+ """The identifier a contribution carries — `ADR-0004` point 1.
2
+
3
+ An opaque per-install UUID, minted once and never derived from anything about the
4
+ machine or its owner. It exists so the corpus can tell two contributions apart
5
+ from one client retrying, which is the distinction `contributor_count` cannot make
6
+ on its own. It is not an identity: there is no registration, no lookup, and the
7
+ server never needs to know more.
8
+
9
+ Two rules the caller has to keep, because this module cannot keep them for it:
10
+
11
+ **Mint it on first contribution, not on install.** `ADR-0009` point 4 — nothing
12
+ leaves the machine by default — covers the fact that an install exists. A tool
13
+ whose user only ever searched their own files has no reason to carry one, and a
14
+ dry run should not create one.
15
+
16
+ **Store it somewhere the user can find and delete.** Deleting it makes the user a
17
+ new contributor and changes nothing else. A tool that hides it has broken the one
18
+ promise the identifier makes.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import uuid
24
+ from pathlib import Path
25
+
26
+
27
+ def mint_client_id() -> str:
28
+ """A fresh identifier. Call it once per install, and only when contributing."""
29
+ return str(uuid.uuid4())
30
+
31
+
32
+ def ensure_client_id(path: str | Path) -> str:
33
+ """The identifier stored at `path`, minting and writing one if there is none.
34
+
35
+ For tools with no better place to keep it. A tool that already has a settings
36
+ store — Familiar's `settings.json`, beets' config, the CLI's `index.json` —
37
+ should keep it there and call `mint_client_id` itself, so the user has one
38
+ place to look rather than two.
39
+ """
40
+ p = Path(path)
41
+ if p.exists():
42
+ existing = p.read_text().strip()
43
+ if existing:
44
+ return existing
45
+ fresh = mint_client_id()
46
+ p.parent.mkdir(parents=True, exist_ok=True)
47
+ p.write_text(fresh + "\n")
48
+ return fresh
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: clapback-client
3
+ Version: 0.1.0
4
+ Summary: Contribute to and look up the clapback commons — the contract a tool follows, with no dependency beyond the standard library
5
+ Project-URL: Homepage, https://clapback.seethroughlab.com
6
+ Project-URL: Repository, https://github.com/seethroughlab/clapback
7
+ Project-URL: Decisions, https://github.com/seethroughlab/clapback/tree/main/docs/decisions
8
+ Author-email: Jeff Crouse <jeff@seethroughlab.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Jeff Crouse
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: acoustid,audio,clap,commons,embeddings,music
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
39
+ Requires-Python: >=3.11
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
42
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # clapback-client
46
+
47
+ The contract a tool follows to take part in the [clapback](https://clapback.seethroughlab.com)
48
+ commons — look up before you contribute, send what the records require, back off when told to —
49
+ with no dependency beyond the standard library.
50
+
51
+ ```bash
52
+ pip install clapback-client
53
+ ```
54
+
55
+ ```python
56
+ from clapback_client import Corpus, fingerprint_file, hash_fingerprint
57
+
58
+ key = hash_fingerprint(fingerprint_file("track.flac"))
59
+ corpus = Corpus()
60
+
61
+ row = corpus.lookup(key, pipeline_version)
62
+ if row is not None:
63
+ vector = row["embedding"] # the commons already had it — skip the model
64
+ else:
65
+ vector = my_embedder(path) # your pipeline, declared by `pipeline_version`
66
+ corpus.contribute(
67
+ fingerprint_hash=key,
68
+ embedding=vector,
69
+ pipeline_version=pipeline_version,
70
+ client_id=client_id,
71
+ )
72
+ ```
73
+
74
+ ## What a tool has to do
75
+
76
+ Four things. Each is decided in one of the project's
77
+ [records](https://github.com/seethroughlab/clapback/tree/main/docs/decisions), and this package
78
+ is those decisions as code so a tool does not have to reimplement them.
79
+
80
+ 1. **Fingerprint the audio and hash it canonically.** `hash_fingerprint` is SHA256 of the
81
+ AcoustID fingerprint exactly as chromaprint returns it — not as your database happened to
82
+ store it. That distinction split a corpus once; `canonical()` is the guard.
83
+ 2. **Produce the vector through a declared pipeline.** This package never embeds. The reference
84
+ pipeline is [`clapback-embed`](https://pypi.org/project/clapback-embed/), whose
85
+ `PIPELINE_VERSION` is the identity to send. A tool with its own pipeline declares its own
86
+ identity, and its vectors are comparable with each other rather than with the reference's.
87
+ 3. **Look up before contributing.** `Corpus.has` or `Corpus.lookup`. A repeat submission is
88
+ recorded as *agreement*, so a tool that re-sent its library would manufacture evidence of one
89
+ install agreeing with itself — the one measurement the commons exists to make honestly.
90
+ 4. **Send `client_id` and `pipeline_version`.** Both are required by `Corpus.contribute` and have
91
+ no defaults. `client_id` is a random UUID minted once per install — `identity.mint_client_id`
92
+ — on the first contribution, never on install, and stored where the user can find and delete
93
+ it.
94
+
95
+ ## What you get back
96
+
97
+ - **Skip the recompute.** `lookup` returns the stored vector for a recording the commons already
98
+ holds under your pipeline.
99
+ - **Similarity across libraries you do not own** — `/v1/similar`, once the corpus's recording-id
100
+ key lands.
101
+ - **Confirmation** — whether your vector for a recording agrees with others' independently
102
+ computed one.
103
+
104
+ The commons is worth exactly its coverage of the library asking. Early on, expect misses.
105
+
106
+ ## Fingerprinting needs chromaprint, and only fingerprinting does
107
+
108
+ ```bash
109
+ brew install chromaprint # or: apt install libchromaprint-tools
110
+ pip install pyacoustid
111
+ ```
112
+
113
+ `fingerprint_file` runs it out of process, because chromaprint is a C library that crashes rather
114
+ than raises on some malformed inputs and one bad file must not end a run. If your tool already has
115
+ fingerprints — beets' `chroma` plugin stores them, Picard computes them natively — hand them to
116
+ `hash_fingerprint` directly and skip this.
117
+
118
+ ## Opt-in, off by default
119
+
120
+ Nothing in this package sends anything until you call `contribute`. A tool that embeds this should
121
+ keep contribution a separate, explicit setting from lookup, and should tell the user what leaves
122
+ the machine — a 512-float vector and a one-way hash, never audio, filenames, or metadata — before
123
+ the first time it does.
@@ -0,0 +1,8 @@
1
+ clapback_client/__init__.py,sha256=CdrNeGf88SvTvkIzcrolkUG5Ek3EvWW3Cy6KxEJYBps,1704
2
+ clapback_client/corpus.py,sha256=i4wImiAw2sbr6gReWm31JE4CEcS58lfoyoKxi_rwIT4,7459
3
+ clapback_client/fingerprint.py,sha256=t_O56nQvcOqN3pBJ65K72nnwA0HmD2kOa8FbHXNfscY,5356
4
+ clapback_client/identity.py,sha256=SZZRZFRToBWZ5FIunID7cyHl-hUllEYS7waozUG-OLw,1868
5
+ clapback_client-0.1.0.dist-info/METADATA,sha256=7NkeZF_IfRtc0Uhm_Ynm8kz0betVviFDCuGBZu8K8vA,5946
6
+ clapback_client-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ clapback_client-0.1.0.dist-info/licenses/LICENSE,sha256=6Tfw3KwvxvDfednEoMB-s180r3b0xff9qWj57iXpiGs,1068
8
+ clapback_client-0.1.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 Jeff Crouse
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.