clapback-client 0.1.0__tar.gz
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.
- clapback_client-0.1.0/.gitignore +45 -0
- clapback_client-0.1.0/LICENSE +21 -0
- clapback_client-0.1.0/PKG-INFO +123 -0
- clapback_client-0.1.0/README.md +79 -0
- clapback_client-0.1.0/pyproject.toml +46 -0
- clapback_client-0.1.0/src/clapback_client/__init__.py +46 -0
- clapback_client-0.1.0/src/clapback_client/corpus.py +163 -0
- clapback_client-0.1.0/src/clapback_client/fingerprint.py +127 -0
- clapback_client-0.1.0/src/clapback_client/identity.py +48 -0
- clapback_client-0.1.0/tests/test_corpus.py +197 -0
- clapback_client-0.1.0/tests/test_fingerprint.py +80 -0
- clapback_client-0.1.0/tests/test_identity.py +36 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
ENV/
|
|
10
|
+
.eggs/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
*.egg
|
|
13
|
+
|
|
14
|
+
# uv — `ADR-0005` point 7. The workspace has exactly one lock, at the root, and it
|
|
15
|
+
# is committed. A lock never ships in a wheel: what `clapback-embed`'s dependents
|
|
16
|
+
# resolve comes from its `dependencies` ranges, so committing this cannot pin them.
|
|
17
|
+
# What it does pin is development, CI and the deployed image, which previously
|
|
18
|
+
# resolved fresh on every Docker build.
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
|
|
26
|
+
# Testing
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.coverage
|
|
29
|
+
htmlcov/
|
|
30
|
+
|
|
31
|
+
# Docker
|
|
32
|
+
data/
|
|
33
|
+
|
|
34
|
+
# Environment
|
|
35
|
+
.env
|
|
36
|
+
.env.local
|
|
37
|
+
|
|
38
|
+
# OS
|
|
39
|
+
.DS_Store
|
|
40
|
+
Thumbs.db
|
|
41
|
+
|
|
42
|
+
# Package venvs and exported model artifacts (614 MB, produced by scripts/export_models.py)
|
|
43
|
+
packages/*/.venv/
|
|
44
|
+
*.onnx
|
|
45
|
+
*.onnx.data
|
|
@@ -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.
|
|
@@ -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,79 @@
|
|
|
1
|
+
# clapback-client
|
|
2
|
+
|
|
3
|
+
The contract a tool follows to take part in the [clapback](https://clapback.seethroughlab.com)
|
|
4
|
+
commons — look up before you contribute, send what the records require, back off when told to —
|
|
5
|
+
with no dependency beyond the standard library.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install clapback-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from clapback_client import Corpus, fingerprint_file, hash_fingerprint
|
|
13
|
+
|
|
14
|
+
key = hash_fingerprint(fingerprint_file("track.flac"))
|
|
15
|
+
corpus = Corpus()
|
|
16
|
+
|
|
17
|
+
row = corpus.lookup(key, pipeline_version)
|
|
18
|
+
if row is not None:
|
|
19
|
+
vector = row["embedding"] # the commons already had it — skip the model
|
|
20
|
+
else:
|
|
21
|
+
vector = my_embedder(path) # your pipeline, declared by `pipeline_version`
|
|
22
|
+
corpus.contribute(
|
|
23
|
+
fingerprint_hash=key,
|
|
24
|
+
embedding=vector,
|
|
25
|
+
pipeline_version=pipeline_version,
|
|
26
|
+
client_id=client_id,
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What a tool has to do
|
|
31
|
+
|
|
32
|
+
Four things. Each is decided in one of the project's
|
|
33
|
+
[records](https://github.com/seethroughlab/clapback/tree/main/docs/decisions), and this package
|
|
34
|
+
is those decisions as code so a tool does not have to reimplement them.
|
|
35
|
+
|
|
36
|
+
1. **Fingerprint the audio and hash it canonically.** `hash_fingerprint` is SHA256 of the
|
|
37
|
+
AcoustID fingerprint exactly as chromaprint returns it — not as your database happened to
|
|
38
|
+
store it. That distinction split a corpus once; `canonical()` is the guard.
|
|
39
|
+
2. **Produce the vector through a declared pipeline.** This package never embeds. The reference
|
|
40
|
+
pipeline is [`clapback-embed`](https://pypi.org/project/clapback-embed/), whose
|
|
41
|
+
`PIPELINE_VERSION` is the identity to send. A tool with its own pipeline declares its own
|
|
42
|
+
identity, and its vectors are comparable with each other rather than with the reference's.
|
|
43
|
+
3. **Look up before contributing.** `Corpus.has` or `Corpus.lookup`. A repeat submission is
|
|
44
|
+
recorded as *agreement*, so a tool that re-sent its library would manufacture evidence of one
|
|
45
|
+
install agreeing with itself — the one measurement the commons exists to make honestly.
|
|
46
|
+
4. **Send `client_id` and `pipeline_version`.** Both are required by `Corpus.contribute` and have
|
|
47
|
+
no defaults. `client_id` is a random UUID minted once per install — `identity.mint_client_id`
|
|
48
|
+
— on the first contribution, never on install, and stored where the user can find and delete
|
|
49
|
+
it.
|
|
50
|
+
|
|
51
|
+
## What you get back
|
|
52
|
+
|
|
53
|
+
- **Skip the recompute.** `lookup` returns the stored vector for a recording the commons already
|
|
54
|
+
holds under your pipeline.
|
|
55
|
+
- **Similarity across libraries you do not own** — `/v1/similar`, once the corpus's recording-id
|
|
56
|
+
key lands.
|
|
57
|
+
- **Confirmation** — whether your vector for a recording agrees with others' independently
|
|
58
|
+
computed one.
|
|
59
|
+
|
|
60
|
+
The commons is worth exactly its coverage of the library asking. Early on, expect misses.
|
|
61
|
+
|
|
62
|
+
## Fingerprinting needs chromaprint, and only fingerprinting does
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
brew install chromaprint # or: apt install libchromaprint-tools
|
|
66
|
+
pip install pyacoustid
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`fingerprint_file` runs it out of process, because chromaprint is a C library that crashes rather
|
|
70
|
+
than raises on some malformed inputs and one bad file must not end a run. If your tool already has
|
|
71
|
+
fingerprints — beets' `chroma` plugin stores them, Picard computes them natively — hand them to
|
|
72
|
+
`hash_fingerprint` directly and skip this.
|
|
73
|
+
|
|
74
|
+
## Opt-in, off by default
|
|
75
|
+
|
|
76
|
+
Nothing in this package sends anything until you call `contribute`. A tool that embeds this should
|
|
77
|
+
keep contribution a separate, explicit setting from lookup, and should tell the user what leaves
|
|
78
|
+
the machine — a 512-float vector and a one-way hash, never audio, filenames, or metadata — before
|
|
79
|
+
the first time it does.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "clapback-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Contribute to and look up the clapback commons — the contract a tool follows, with no dependency beyond the standard library"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Jeff Crouse", email = "jeff@seethroughlab.com" }]
|
|
9
|
+
keywords = ["clap", "audio", "embeddings", "music", "acoustid", "commons"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# `ADR-0011` point 2: nothing beyond the standard library. A tool that already has
|
|
21
|
+
# an embedder and only wants to contribute under its own pipeline identity must not
|
|
22
|
+
# have to install ONNX Runtime to do so. Fingerprinting shells out to pyacoustid in a
|
|
23
|
+
# subprocess and is optional at import time — see `fingerprint.py`.
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=7.4.0", "ruff>=0.1.0"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://clapback.seethroughlab.com"
|
|
31
|
+
Repository = "https://github.com/seethroughlab/clapback"
|
|
32
|
+
Decisions = "https://github.com/seethroughlab/clapback/tree/main/docs/decisions"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/clapback_client"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py311"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -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,197 @@
|
|
|
1
|
+
"""What goes over the wire, and what the client does when the corpus pushes back.
|
|
2
|
+
|
|
3
|
+
No network. `urllib.request.urlopen` is replaced with a fake that records every
|
|
4
|
+
request and answers from a script, so each test states exactly which HTTP
|
|
5
|
+
exchange it is about. The properties here are the ones a plug-in author would
|
|
6
|
+
otherwise have to rediscover: the query-string escaping that turns a `+` into a
|
|
7
|
+
miss, the lookup that must precede every write, the defaults derived from the
|
|
8
|
+
pipeline identity, and the backoff that keeps a tool from being the client an
|
|
9
|
+
operator has to block.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from urllib.parse import parse_qs, urlsplit
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from clapback_client import corpus as corpus_mod
|
|
23
|
+
from clapback_client.corpus import Corpus, CorpusError
|
|
24
|
+
|
|
25
|
+
PIPELINE = "laion/clap-htsat-unfused+frontend1+artifact1+pool1+fp32"
|
|
26
|
+
HASH = "d1" * 32
|
|
27
|
+
VECTOR = [0.0] * 511 + [1.0]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeResponse(io.BytesIO):
|
|
31
|
+
def __init__(self, status: int, body: dict | None):
|
|
32
|
+
super().__init__(json.dumps(body).encode() if body is not None else b"")
|
|
33
|
+
self.status = status
|
|
34
|
+
|
|
35
|
+
def __enter__(self):
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *a):
|
|
39
|
+
self.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def wire(monkeypatch):
|
|
44
|
+
"""A scripted server. Append (status, body) answers; read `.requests` back."""
|
|
45
|
+
log = []
|
|
46
|
+
answers = []
|
|
47
|
+
|
|
48
|
+
def fake_urlopen(req, timeout=None):
|
|
49
|
+
log.append(req)
|
|
50
|
+
status, body = answers.pop(0)
|
|
51
|
+
if status >= 400:
|
|
52
|
+
raise urllib.error.HTTPError(
|
|
53
|
+
req.full_url, status, "err", {}, io.BytesIO(json.dumps(body or {}).encode())
|
|
54
|
+
)
|
|
55
|
+
return FakeResponse(status, body)
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
58
|
+
monkeypatch.setattr(corpus_mod.time, "sleep", lambda s: log.append(("slept", s)))
|
|
59
|
+
|
|
60
|
+
class Wire:
|
|
61
|
+
requests = log
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def answer(status, body=None):
|
|
65
|
+
answers.append((status, body))
|
|
66
|
+
|
|
67
|
+
return Wire
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestTheLookup:
|
|
71
|
+
def test_the_pipeline_identity_is_escaped(self, wire):
|
|
72
|
+
"""`+` means a space in a query string, so an unescaped identity 404s as
|
|
73
|
+
though the recording were absent. `ADR-0006` records what that cost."""
|
|
74
|
+
wire.answer(404)
|
|
75
|
+
Corpus("https://x.invalid").lookup(HASH, PIPELINE)
|
|
76
|
+
url = wire.requests[0].full_url
|
|
77
|
+
assert "+" not in urlsplit(url).query
|
|
78
|
+
assert parse_qs(urlsplit(url).query)["pipeline_version"] == [PIPELINE]
|
|
79
|
+
|
|
80
|
+
def test_a_row_comes_back_whole(self, wire):
|
|
81
|
+
row = {"fingerprint_hash": HASH, "embedding": VECTOR, "contributor_count": 2}
|
|
82
|
+
wire.answer(200, row)
|
|
83
|
+
assert Corpus("https://x.invalid").lookup(HASH, PIPELINE) == row
|
|
84
|
+
|
|
85
|
+
def test_absent_is_none_and_has_is_false(self, wire):
|
|
86
|
+
wire.answer(404)
|
|
87
|
+
wire.answer(404)
|
|
88
|
+
c = Corpus("https://x.invalid")
|
|
89
|
+
assert c.lookup(HASH, PIPELINE) is None
|
|
90
|
+
assert c.has(HASH, PIPELINE) is False
|
|
91
|
+
|
|
92
|
+
def test_anything_else_is_an_error_not_a_miss(self, wire):
|
|
93
|
+
"""A 500 that read as 'absent' would contribute a duplicate on the retry.
|
|
94
|
+
Familiar's backfill made exactly this distinction for the same reason."""
|
|
95
|
+
wire.answer(500)
|
|
96
|
+
with pytest.raises(CorpusError):
|
|
97
|
+
Corpus("https://x.invalid").lookup(HASH, PIPELINE)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestTheContribution:
|
|
101
|
+
def _sent(self, wire):
|
|
102
|
+
return json.loads(wire.requests[-1].data)
|
|
103
|
+
|
|
104
|
+
def test_it_sends_what_the_records_require(self, wire):
|
|
105
|
+
"""`ADR-0006` point 4 and `ADR-0004` point 1: no defaults for these two."""
|
|
106
|
+
wire.answer(201, {"status": "created"})
|
|
107
|
+
Corpus("https://x.invalid").contribute(
|
|
108
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c-1"
|
|
109
|
+
)
|
|
110
|
+
body = self._sent(wire)
|
|
111
|
+
assert body["pipeline_version"] == PIPELINE
|
|
112
|
+
assert body["client_id"] == "c-1"
|
|
113
|
+
assert body["fingerprint_hash"] == HASH
|
|
114
|
+
assert len(body["embedding"]) == 512
|
|
115
|
+
|
|
116
|
+
def test_the_checkpoint_is_derived_from_the_identity(self, wire):
|
|
117
|
+
"""Taken from the identity rather than written twice, so the two cannot
|
|
118
|
+
disagree about one fact."""
|
|
119
|
+
wire.answer(201, {})
|
|
120
|
+
Corpus("https://x.invalid").contribute(
|
|
121
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c"
|
|
122
|
+
)
|
|
123
|
+
assert self._sent(wire)["clap_model_version"] == "laion/clap-htsat-unfused"
|
|
124
|
+
assert self._sent(wire)["analysis_version"] == 1
|
|
125
|
+
|
|
126
|
+
def test_a_caller_may_override_the_recorded_columns(self, wire):
|
|
127
|
+
wire.answer(201, {})
|
|
128
|
+
Corpus("https://x.invalid").contribute(
|
|
129
|
+
fingerprint_hash=HASH,
|
|
130
|
+
embedding=VECTOR,
|
|
131
|
+
pipeline_version=PIPELINE,
|
|
132
|
+
client_id="c",
|
|
133
|
+
clap_model_version="laion/clap-htsat-unfused:v1",
|
|
134
|
+
analysis_version=8,
|
|
135
|
+
)
|
|
136
|
+
assert self._sent(wire)["clap_model_version"] == "laion/clap-htsat-unfused:v1"
|
|
137
|
+
assert self._sent(wire)["analysis_version"] == 8
|
|
138
|
+
|
|
139
|
+
def test_it_names_itself(self, wire):
|
|
140
|
+
"""Not identity — that is `client_id` — but an operator reading logs
|
|
141
|
+
should be able to tell this from a browser."""
|
|
142
|
+
wire.answer(201, {})
|
|
143
|
+
Corpus("https://x.invalid").contribute(
|
|
144
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c"
|
|
145
|
+
)
|
|
146
|
+
assert wire.requests[-1].get_header("User-agent") == "clapback-client"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestWhenTheCorpusPushesBack:
|
|
150
|
+
def test_a_rate_limit_is_waited_out_then_retried(self, wire):
|
|
151
|
+
wire.answer(429)
|
|
152
|
+
wire.answer(429)
|
|
153
|
+
wire.answer(201, {})
|
|
154
|
+
result = Corpus("https://x.invalid").contribute(
|
|
155
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c"
|
|
156
|
+
)
|
|
157
|
+
assert result == "contributed"
|
|
158
|
+
posts = [r for r in wire.requests if not isinstance(r, tuple)]
|
|
159
|
+
sleeps = [r for r in wire.requests if isinstance(r, tuple)]
|
|
160
|
+
assert len(posts) == 3
|
|
161
|
+
assert [s for _, s in sleeps] == list(corpus_mod._RETRY_DELAYS[:2])
|
|
162
|
+
|
|
163
|
+
def test_a_persistent_rate_limit_gives_up_cleanly(self, wire):
|
|
164
|
+
for _ in range(len(corpus_mod._RETRY_DELAYS) + 1):
|
|
165
|
+
wire.answer(429)
|
|
166
|
+
with pytest.raises(CorpusError, match="rate limited"):
|
|
167
|
+
Corpus("https://x.invalid").contribute(
|
|
168
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def test_a_malformed_submission_is_reported_not_retried(self, wire):
|
|
172
|
+
"""A 422 is the server saying what was wrong. Retrying it is noise."""
|
|
173
|
+
wire.answer(422, {"detail": "pipeline_version: field required"})
|
|
174
|
+
with pytest.raises(CorpusError, match="malformed"):
|
|
175
|
+
Corpus("https://x.invalid").contribute(
|
|
176
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c"
|
|
177
|
+
)
|
|
178
|
+
assert len([r for r in wire.requests if not isinstance(r, tuple)]) == 1
|
|
179
|
+
|
|
180
|
+
def test_a_full_corpus_is_the_corpus_working(self, wire):
|
|
181
|
+
"""`ADR-0004` point 9's ceiling. Stop rather than hammer it."""
|
|
182
|
+
wire.answer(507, {"detail": "ceiling"})
|
|
183
|
+
with pytest.raises(CorpusError, match="full"):
|
|
184
|
+
Corpus("https://x.invalid").contribute(
|
|
185
|
+
fingerprint_hash=HASH, embedding=VECTOR, pipeline_version=PIPELINE, client_id="c"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TestTheContractIsOnlyHTTP:
|
|
190
|
+
def test_nothing_here_opens_a_database(self):
|
|
191
|
+
"""`ADR-0005` point 12: a direct connection is a second write path with
|
|
192
|
+
none of the guarantees. A client must not even be able to."""
|
|
193
|
+
import inspect
|
|
194
|
+
|
|
195
|
+
src = inspect.getsource(corpus_mod)
|
|
196
|
+
for forbidden in ("psycopg", "asyncpg", "sqlalchemy", "postgresql://"):
|
|
197
|
+
assert forbidden not in src
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""`ADR-0010`: the corpus key is a function of the audio.
|
|
2
|
+
|
|
3
|
+
The defect this guards against did not look like a bug. Familiar hashed the
|
|
4
|
+
fingerprint it had stored, which was correct code given a canonical input and was
|
|
5
|
+
never given one — so the same recording reached the corpus under two keys
|
|
6
|
+
depending on a column's migration history. These tests are the rule stated as
|
|
7
|
+
assertions, because the failure is silent: a wrong key is a well-formed 64-character
|
|
8
|
+
hex string that simply matches nothing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
from clapback_client.fingerprint import canonical, hash_fingerprint
|
|
16
|
+
|
|
17
|
+
#: A real-shaped chromaprint fingerprint: base64 alphabet, starts `AQAD`.
|
|
18
|
+
RAW = "AQADtJESbVkUhYL84z4CnwZ4HsdxHD6P4_hx_EAO_cjx"
|
|
19
|
+
|
|
20
|
+
#: The same value after a `bytea` → `text` column migration renders it in
|
|
21
|
+
#: Postgres's hex output format. Not a different fingerprint — the same ASCII.
|
|
22
|
+
ESCAPED = "\\x" + RAW.encode().hex()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_escaped_and_raw_are_the_same_fingerprint():
|
|
26
|
+
"""The premise. If this fails the rest of the file is measuring nothing."""
|
|
27
|
+
assert bytes.fromhex(ESCAPED[2:]).decode() == RAW
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_the_two_encodings_hash_alike():
|
|
31
|
+
"""The whole point of `ADR-0010`.
|
|
32
|
+
|
|
33
|
+
Before it, these differed, and that difference is what split the corpus into
|
|
34
|
+
14,284 rows keyed one way and 11,364 the other.
|
|
35
|
+
"""
|
|
36
|
+
assert hash_fingerprint(RAW) == hash_fingerprint(ESCAPED)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_the_hash_is_of_the_fingerprint_itself():
|
|
40
|
+
"""Canonical means the bytes chromaprint returned, not a form of our choosing.
|
|
41
|
+
|
|
42
|
+
Pinned against `hashlib` directly rather than against `hash_fingerprint`'s own
|
|
43
|
+
output, so that a change to the canonical form fails here instead of quietly
|
|
44
|
+
re-keying the corpus.
|
|
45
|
+
"""
|
|
46
|
+
assert hash_fingerprint(RAW) == hashlib.sha256(RAW.encode()).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_bytes_and_str_agree():
|
|
50
|
+
"""`acoustid` returns bytes on some paths and str on others."""
|
|
51
|
+
assert hash_fingerprint(RAW.encode()) == hash_fingerprint(RAW)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_a_real_fingerprint_is_never_mistaken_for_an_encoding():
|
|
55
|
+
"""The decode must not misfire on the values it will actually see.
|
|
56
|
+
|
|
57
|
+
A chromaprint fingerprint is base64 and cannot begin with a backslash, which
|
|
58
|
+
is what makes the narrow check safe.
|
|
59
|
+
"""
|
|
60
|
+
assert canonical(RAW) == RAW.encode()
|
|
61
|
+
assert not RAW.startswith("\\x")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_unrecognised_shapes_are_left_alone():
|
|
65
|
+
"""Anything not recognisably a re-encoding passes through unchanged.
|
|
66
|
+
|
|
67
|
+
Guessing would be worse than not trying: a wrong guess produces a key that is
|
|
68
|
+
wrong in a new way, and nothing downstream can tell.
|
|
69
|
+
"""
|
|
70
|
+
for odd in ("\\xZZZZ", "\\x41514", "\\x", "not a fingerprint", ""):
|
|
71
|
+
assert canonical(odd) == odd.encode()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_escaped_non_ascii_is_not_decoded():
|
|
75
|
+
"""Valid hex that decodes to arbitrary bytes is not a re-encoded fingerprint.
|
|
76
|
+
|
|
77
|
+
`\\x00ff` is well-formed hex, so only the printable-ASCII test separates it
|
|
78
|
+
from an escaped fingerprint.
|
|
79
|
+
"""
|
|
80
|
+
assert canonical("\\x00ff") == b"\\x00ff"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""`ADR-0004` point 1 — an identifier, not an identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from clapback_client.identity import ensure_client_id, mint_client_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestMinting:
|
|
11
|
+
def test_it_is_a_uuid_and_nothing_else(self):
|
|
12
|
+
"""Derived from nothing about the machine or its owner: two mints on the
|
|
13
|
+
same machine differ, and each parses as a random UUID."""
|
|
14
|
+
a, b = mint_client_id(), mint_client_id()
|
|
15
|
+
assert a != b
|
|
16
|
+
assert uuid.UUID(a).version == 4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestTheFileHelper:
|
|
20
|
+
def test_it_mints_once_and_then_returns_the_same_one(self, tmp_path):
|
|
21
|
+
p = tmp_path / "deep" / "client_id"
|
|
22
|
+
first = ensure_client_id(p)
|
|
23
|
+
assert p.read_text().strip() == first
|
|
24
|
+
assert ensure_client_id(p) == first
|
|
25
|
+
|
|
26
|
+
def test_deleting_it_makes_a_new_contributor(self, tmp_path):
|
|
27
|
+
"""The one promise the identifier makes to the user."""
|
|
28
|
+
p = tmp_path / "client_id"
|
|
29
|
+
first = ensure_client_id(p)
|
|
30
|
+
p.unlink()
|
|
31
|
+
assert ensure_client_id(p) != first
|
|
32
|
+
|
|
33
|
+
def test_an_empty_file_is_treated_as_absent(self, tmp_path):
|
|
34
|
+
p = tmp_path / "client_id"
|
|
35
|
+
p.write_text("\n")
|
|
36
|
+
assert uuid.UUID(ensure_client_id(p))
|