perfume-api 2.4.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.
- perfume_api-2.4.0/.gitignore +4 -0
- perfume_api-2.4.0/LICENSE +21 -0
- perfume_api-2.4.0/PKG-INFO +198 -0
- perfume_api-2.4.0/README.md +179 -0
- perfume_api-2.4.0/pyproject.toml +29 -0
- perfume_api-2.4.0/src/perfume_api/__init__.py +273 -0
- perfume_api-2.4.0/src/perfume_api/py.typed +0 -0
- perfume_api-2.4.0/src/perfume_api/types.py +274 -0
- perfume_api-2.4.0/tests/smoke.py +38 -0
- perfume_api-2.4.0/tests/test_client.py +103 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 10ML
|
|
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,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: perfume-api
|
|
3
|
+
Version: 2.4.0
|
|
4
|
+
Summary: Free perfume API client: perfumes, note pyramids, accords, similar scents, notes and houses from the 10ML Fragrance Index. No API key, JSON over HTTPS.
|
|
5
|
+
Project-URL: Homepage, https://mcp.10ml.com
|
|
6
|
+
Project-URL: Documentation, https://mcp.10ml.com
|
|
7
|
+
Author-email: 10ML Team <contact@10ml.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: chatgpt,claude,cologne,cursor,eau-de-parfum,fragrance,fragrance-api,fragrance-mcp,fragrance-notes,mcp,mcp-server,model-context-protocol,perfume,perfume-api,perfume-mcp,perfume-notes,perfume-recommendation,scent,similar-perfumes
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# perfume-api: free perfume API client (10ML Fragrance Index)
|
|
21
|
+
|
|
22
|
+
Free perfume API client: perfumes, note pyramids, accords, similar scents, notes and houses from the 10ML Fragrance Index, curated by the fragrance editors of [10ML](https://10ml.com). No API key, no dependencies, typed for Python.
|
|
23
|
+
|
|
24
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=com.10ml/perfume-finder)
|
|
25
|
+
[](https://pypi.org/project/perfume-api/)
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install perfume-api
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Needs Python 3.9 or newer (standard library only).
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from perfume_api import search_perfumes, perfume_alternatives, explain_note, compare_perfumes
|
|
39
|
+
|
|
40
|
+
found = search_perfumes("fresh citrus summer cologne for men", limit=3)
|
|
41
|
+
print(found["results"][0]["name"], found["results"][0]["accords"])
|
|
42
|
+
|
|
43
|
+
alts = perfume_alternatives("Baccarat Rouge 540")
|
|
44
|
+
print([f'{a["name"]} ({a["closeness"]})' for a in alts["alternatives"]])
|
|
45
|
+
|
|
46
|
+
note = explain_note("vanilla")
|
|
47
|
+
if note["found"]:
|
|
48
|
+
print(note["note"]["smells_like"])
|
|
49
|
+
|
|
50
|
+
cmp = compare_perfumes(["Delina", "Layton"])
|
|
51
|
+
print(cmp["shared_notes"], cmp["only"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Functions
|
|
55
|
+
|
|
56
|
+
| Function | REST route | What it does |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `search_perfumes(q, ...)` | `GET /perfumes` | Search by free text, house, notes, accords, family, audience, season, occasion or style |
|
|
59
|
+
| `get_perfume(id, ...)` | `GET /perfumes/{id}` | Full profile of one perfume: note pyramid, accords, seasons, similar scents |
|
|
60
|
+
| `similar_perfumes(perfume, ...)` | `GET /similar` | Perfumes that smell like one you name, or like a list of notes and accords |
|
|
61
|
+
| `perfume_alternatives(perfume, ...)` | `GET /alternatives` | The editors' alternatives to a famous perfume, from other houses |
|
|
62
|
+
| `explain_note(id, ...)` | `GET /notes/{id}` | What a note smells like, what it pairs with, where it leads |
|
|
63
|
+
| `get_house(id, ...)` | `GET /houses/{id}` | A perfume house: country, style, signature perfumes |
|
|
64
|
+
| `list_houses(...)` | `GET /houses` | Browse houses by type, country or name |
|
|
65
|
+
| `search_guides(q, ...)` | `GET /guides` | 10ML perfume guides and articles to read or cite |
|
|
66
|
+
| `compare_perfumes(names)` | `GET /perfumes/{id} x 2-4` | Shared and distinct notes and accords of 2 to 4 perfumes (built from their profiles; the REST API has no compare route) |
|
|
67
|
+
|
|
68
|
+
## What comes back
|
|
69
|
+
|
|
70
|
+
Plain JSON, as the REST API sends it (shown trimmed to the main fields). For example `GET /alternatives` and `GET /notes/vanilla`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
curl -s "https://mcp.10ml.com/api/v1/alternatives?perfume=Baccarat+Rouge+540&limit=2"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"total": 6,
|
|
79
|
+
"original": {
|
|
80
|
+
"name": "Maison Francis Kurkdjian Baccarat Rouge 540",
|
|
81
|
+
"id": "maison-francis-kurkdjian-baccarat-rouge-540-edp"
|
|
82
|
+
},
|
|
83
|
+
"alternatives": [
|
|
84
|
+
{
|
|
85
|
+
"name": "Maison Alhambra Baroque Rouge Extrait",
|
|
86
|
+
"closeness": "very close",
|
|
87
|
+
"why": "Sugared amber, saffron and airy woods aim at the same luminous effect.",
|
|
88
|
+
"id": "maison-alhambra-baroque-rouge-extrait",
|
|
89
|
+
"house": "Maison Alhambra",
|
|
90
|
+
"source": "editors"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "Dossier Ambery Saffron",
|
|
94
|
+
"closeness": "similar vibe",
|
|
95
|
+
"why": "Saffron and amber with sweet woods share the glowing amber idea.",
|
|
96
|
+
"id": "dossier-ambery-saffron",
|
|
97
|
+
"house": "Dossier",
|
|
98
|
+
"source": "editors"
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"source": "10ML Fragrance Index"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -s "https://mcp.10ml.com/api/v1/notes/vanilla"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"found": true,
|
|
112
|
+
"note": {
|
|
113
|
+
"id": "vanilla",
|
|
114
|
+
"name": "vanilla",
|
|
115
|
+
"family": "gourmand",
|
|
116
|
+
"smells_like": "Warm, creamy, sweet and comforting, like custard and cured pods.",
|
|
117
|
+
"mood": "comforting, sweet, warm",
|
|
118
|
+
"pairs_with": [
|
|
119
|
+
"tonka bean",
|
|
120
|
+
"amber",
|
|
121
|
+
"sandalwood",
|
|
122
|
+
"tobacco",
|
|
123
|
+
"caramel",
|
|
124
|
+
"patchouli"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"source": "10ML Fragrance Index"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
An unknown id or name is not an error: the call returns the body with `found: False` and suggestions. A bad request (HTTP 400) or any other failure raises `PerfumeApiError` with `status`, `code`, `message` and the parsed `body`.
|
|
134
|
+
|
|
135
|
+
## Configure
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from perfume_api import PerfumeApi
|
|
139
|
+
|
|
140
|
+
api = PerfumeApi(
|
|
141
|
+
base_url="https://mcp.10ml.com/api/v1", # the default
|
|
142
|
+
timeout=15.0,
|
|
143
|
+
headers={"user-agent": "my-app/1.0"},
|
|
144
|
+
)
|
|
145
|
+
houses = api.list_houses(type="niche", limit=5)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Keyword arguments use the API's own names (`notes_include`, `next_cursor`). List parameters such as `notes_include` take a string or a list. Responses are `TypedDict`s (`SearchPerfumesResponse`, `Perfume`, `Pyramid`, ...) generated from the OpenAPI file; the package ships `py.typed`.
|
|
149
|
+
|
|
150
|
+
## Example answers
|
|
151
|
+
|
|
152
|
+
**"What is a good alternative to Baccarat Rouge 540 from another house?"** (`find_perfume_alternatives`)
|
|
153
|
+
Maison Alhambra Baroque Rouge Extrait (Maison Alhambra), very close: Sugared amber, saffron and airy woods aim at the same luminous effect.
|
|
154
|
+
|
|
155
|
+
**"A fresh citrus summer cologne for men."** (`search_perfumes`)
|
|
156
|
+
Ciao Citrus (Paris Corner): Grapefruit and bergamot over orange blossom and lily of the valley, drying to musk, sandalwood and amber. Also: Citrus Batikanga (Maison Crivelli): Bitter orange, kalamansi and bergamot are pressed fresh, then a pinch of chili and a vetiver-cedar base keep it lively.
|
|
157
|
+
|
|
158
|
+
**"What does vanilla smell like?"** (`explain_note`)
|
|
159
|
+
vanilla, a gourmand note: Warm, creamy, sweet and comforting, like custard and cured pods. Pairs with tonka bean, amber, sandalwood, tobacco.
|
|
160
|
+
|
|
161
|
+
## Languages
|
|
162
|
+
|
|
163
|
+
Every call takes `locale` (`en`, `de`, `fr`, `es`, `it`, `ro`, `el`, `hu`, `cs`, `pl`) for the 10ml.com links in the answer, and `format` (`concise` or `detailed`). Notes are understood in other languages too: "Vanille" finds vanilla.
|
|
164
|
+
|
|
165
|
+
## Limits and fair use
|
|
166
|
+
|
|
167
|
+
Free and read-only, with no key or sign-in. There is no published rate limit: please cache answers, page long lists with `limit` and `cursor`, and send a descriptive User-Agent from automated clients. For heavy use write to contact@10ml.com.
|
|
168
|
+
|
|
169
|
+
## How it compares
|
|
170
|
+
|
|
171
|
+
In our 40-question benchmark of free, no-key perfume MCP servers (20 October 2026): 39 of 40 correct, median 232 ms. [Read the benchmark](https://10ml.com/perfume-mcp-servers-compared).
|
|
172
|
+
|
|
173
|
+
## FAQ
|
|
174
|
+
|
|
175
|
+
**Is there a free perfume API?**
|
|
176
|
+
Yes. This client talks to the 10ML Fragrance Index API at https://mcp.10ml.com/api/v1: JSON over HTTPS, OpenAPI 3.1 at https://mcp.10ml.com/openapi.json, no API key and no sign-up.
|
|
177
|
+
|
|
178
|
+
**Does it have dependencies?**
|
|
179
|
+
No. The npm package uses the built-in `fetch` (Node 18+), the PyPI package uses `urllib` (Python 3.9+).
|
|
180
|
+
|
|
181
|
+
**Is there a fragrance notes API?**
|
|
182
|
+
Yes: `explainNote` / `explain_note` describes a note (what it smells like, family, origin, pairings), and `searchPerfumes` with `notes_include` lists perfumes built around notes.
|
|
183
|
+
|
|
184
|
+
**Can an AI assistant use it directly?**
|
|
185
|
+
Yes, through the 10ML Perfume Finder MCP server (`https://mcp.10ml.com/mcp`, or `npx -y perfume-mcp` / `uvx perfume-mcp`).
|
|
186
|
+
|
|
187
|
+
**What do I get for a perfume?**
|
|
188
|
+
House, launch year, audience, concentration, family, accords, the note pyramid (top, heart, base), seasons and occasions, an editorial character line and links to 10ml.com guides.
|
|
189
|
+
|
|
190
|
+
## Cite as
|
|
191
|
+
|
|
192
|
+
Cite the answers as "the 10ML Fragrance Index", curated by the fragrance editors of [10ML](https://10ml.com).
|
|
193
|
+
|
|
194
|
+
[mcp.10ml.com](https://mcp.10ml.com) · [10ml.com/perfume-api](https://10ml.com/perfume-api) · [Privacy](https://mcp.10ml.com/privacy) · [Terms](https://mcp.10ml.com/terms) · contact@10ml.com
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# perfume-api: free perfume API client (10ML Fragrance Index)
|
|
2
|
+
|
|
3
|
+
Free perfume API client: perfumes, note pyramids, accords, similar scents, notes and houses from the 10ML Fragrance Index, curated by the fragrance editors of [10ML](https://10ml.com). No API key, no dependencies, typed for Python.
|
|
4
|
+
|
|
5
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=com.10ml/perfume-finder)
|
|
6
|
+
[](https://pypi.org/project/perfume-api/)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install perfume-api
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Needs Python 3.9 or newer (standard library only).
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from perfume_api import search_perfumes, perfume_alternatives, explain_note, compare_perfumes
|
|
20
|
+
|
|
21
|
+
found = search_perfumes("fresh citrus summer cologne for men", limit=3)
|
|
22
|
+
print(found["results"][0]["name"], found["results"][0]["accords"])
|
|
23
|
+
|
|
24
|
+
alts = perfume_alternatives("Baccarat Rouge 540")
|
|
25
|
+
print([f'{a["name"]} ({a["closeness"]})' for a in alts["alternatives"]])
|
|
26
|
+
|
|
27
|
+
note = explain_note("vanilla")
|
|
28
|
+
if note["found"]:
|
|
29
|
+
print(note["note"]["smells_like"])
|
|
30
|
+
|
|
31
|
+
cmp = compare_perfumes(["Delina", "Layton"])
|
|
32
|
+
print(cmp["shared_notes"], cmp["only"])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Functions
|
|
36
|
+
|
|
37
|
+
| Function | REST route | What it does |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `search_perfumes(q, ...)` | `GET /perfumes` | Search by free text, house, notes, accords, family, audience, season, occasion or style |
|
|
40
|
+
| `get_perfume(id, ...)` | `GET /perfumes/{id}` | Full profile of one perfume: note pyramid, accords, seasons, similar scents |
|
|
41
|
+
| `similar_perfumes(perfume, ...)` | `GET /similar` | Perfumes that smell like one you name, or like a list of notes and accords |
|
|
42
|
+
| `perfume_alternatives(perfume, ...)` | `GET /alternatives` | The editors' alternatives to a famous perfume, from other houses |
|
|
43
|
+
| `explain_note(id, ...)` | `GET /notes/{id}` | What a note smells like, what it pairs with, where it leads |
|
|
44
|
+
| `get_house(id, ...)` | `GET /houses/{id}` | A perfume house: country, style, signature perfumes |
|
|
45
|
+
| `list_houses(...)` | `GET /houses` | Browse houses by type, country or name |
|
|
46
|
+
| `search_guides(q, ...)` | `GET /guides` | 10ML perfume guides and articles to read or cite |
|
|
47
|
+
| `compare_perfumes(names)` | `GET /perfumes/{id} x 2-4` | Shared and distinct notes and accords of 2 to 4 perfumes (built from their profiles; the REST API has no compare route) |
|
|
48
|
+
|
|
49
|
+
## What comes back
|
|
50
|
+
|
|
51
|
+
Plain JSON, as the REST API sends it (shown trimmed to the main fields). For example `GET /alternatives` and `GET /notes/vanilla`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl -s "https://mcp.10ml.com/api/v1/alternatives?perfume=Baccarat+Rouge+540&limit=2"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"total": 6,
|
|
60
|
+
"original": {
|
|
61
|
+
"name": "Maison Francis Kurkdjian Baccarat Rouge 540",
|
|
62
|
+
"id": "maison-francis-kurkdjian-baccarat-rouge-540-edp"
|
|
63
|
+
},
|
|
64
|
+
"alternatives": [
|
|
65
|
+
{
|
|
66
|
+
"name": "Maison Alhambra Baroque Rouge Extrait",
|
|
67
|
+
"closeness": "very close",
|
|
68
|
+
"why": "Sugared amber, saffron and airy woods aim at the same luminous effect.",
|
|
69
|
+
"id": "maison-alhambra-baroque-rouge-extrait",
|
|
70
|
+
"house": "Maison Alhambra",
|
|
71
|
+
"source": "editors"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "Dossier Ambery Saffron",
|
|
75
|
+
"closeness": "similar vibe",
|
|
76
|
+
"why": "Saffron and amber with sweet woods share the glowing amber idea.",
|
|
77
|
+
"id": "dossier-ambery-saffron",
|
|
78
|
+
"house": "Dossier",
|
|
79
|
+
"source": "editors"
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"source": "10ML Fragrance Index"
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
curl -s "https://mcp.10ml.com/api/v1/notes/vanilla"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"found": true,
|
|
93
|
+
"note": {
|
|
94
|
+
"id": "vanilla",
|
|
95
|
+
"name": "vanilla",
|
|
96
|
+
"family": "gourmand",
|
|
97
|
+
"smells_like": "Warm, creamy, sweet and comforting, like custard and cured pods.",
|
|
98
|
+
"mood": "comforting, sweet, warm",
|
|
99
|
+
"pairs_with": [
|
|
100
|
+
"tonka bean",
|
|
101
|
+
"amber",
|
|
102
|
+
"sandalwood",
|
|
103
|
+
"tobacco",
|
|
104
|
+
"caramel",
|
|
105
|
+
"patchouli"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"source": "10ML Fragrance Index"
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Errors
|
|
113
|
+
|
|
114
|
+
An unknown id or name is not an error: the call returns the body with `found: False` and suggestions. A bad request (HTTP 400) or any other failure raises `PerfumeApiError` with `status`, `code`, `message` and the parsed `body`.
|
|
115
|
+
|
|
116
|
+
## Configure
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from perfume_api import PerfumeApi
|
|
120
|
+
|
|
121
|
+
api = PerfumeApi(
|
|
122
|
+
base_url="https://mcp.10ml.com/api/v1", # the default
|
|
123
|
+
timeout=15.0,
|
|
124
|
+
headers={"user-agent": "my-app/1.0"},
|
|
125
|
+
)
|
|
126
|
+
houses = api.list_houses(type="niche", limit=5)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Keyword arguments use the API's own names (`notes_include`, `next_cursor`). List parameters such as `notes_include` take a string or a list. Responses are `TypedDict`s (`SearchPerfumesResponse`, `Perfume`, `Pyramid`, ...) generated from the OpenAPI file; the package ships `py.typed`.
|
|
130
|
+
|
|
131
|
+
## Example answers
|
|
132
|
+
|
|
133
|
+
**"What is a good alternative to Baccarat Rouge 540 from another house?"** (`find_perfume_alternatives`)
|
|
134
|
+
Maison Alhambra Baroque Rouge Extrait (Maison Alhambra), very close: Sugared amber, saffron and airy woods aim at the same luminous effect.
|
|
135
|
+
|
|
136
|
+
**"A fresh citrus summer cologne for men."** (`search_perfumes`)
|
|
137
|
+
Ciao Citrus (Paris Corner): Grapefruit and bergamot over orange blossom and lily of the valley, drying to musk, sandalwood and amber. Also: Citrus Batikanga (Maison Crivelli): Bitter orange, kalamansi and bergamot are pressed fresh, then a pinch of chili and a vetiver-cedar base keep it lively.
|
|
138
|
+
|
|
139
|
+
**"What does vanilla smell like?"** (`explain_note`)
|
|
140
|
+
vanilla, a gourmand note: Warm, creamy, sweet and comforting, like custard and cured pods. Pairs with tonka bean, amber, sandalwood, tobacco.
|
|
141
|
+
|
|
142
|
+
## Languages
|
|
143
|
+
|
|
144
|
+
Every call takes `locale` (`en`, `de`, `fr`, `es`, `it`, `ro`, `el`, `hu`, `cs`, `pl`) for the 10ml.com links in the answer, and `format` (`concise` or `detailed`). Notes are understood in other languages too: "Vanille" finds vanilla.
|
|
145
|
+
|
|
146
|
+
## Limits and fair use
|
|
147
|
+
|
|
148
|
+
Free and read-only, with no key or sign-in. There is no published rate limit: please cache answers, page long lists with `limit` and `cursor`, and send a descriptive User-Agent from automated clients. For heavy use write to contact@10ml.com.
|
|
149
|
+
|
|
150
|
+
## How it compares
|
|
151
|
+
|
|
152
|
+
In our 40-question benchmark of free, no-key perfume MCP servers (20 October 2026): 39 of 40 correct, median 232 ms. [Read the benchmark](https://10ml.com/perfume-mcp-servers-compared).
|
|
153
|
+
|
|
154
|
+
## FAQ
|
|
155
|
+
|
|
156
|
+
**Is there a free perfume API?**
|
|
157
|
+
Yes. This client talks to the 10ML Fragrance Index API at https://mcp.10ml.com/api/v1: JSON over HTTPS, OpenAPI 3.1 at https://mcp.10ml.com/openapi.json, no API key and no sign-up.
|
|
158
|
+
|
|
159
|
+
**Does it have dependencies?**
|
|
160
|
+
No. The npm package uses the built-in `fetch` (Node 18+), the PyPI package uses `urllib` (Python 3.9+).
|
|
161
|
+
|
|
162
|
+
**Is there a fragrance notes API?**
|
|
163
|
+
Yes: `explainNote` / `explain_note` describes a note (what it smells like, family, origin, pairings), and `searchPerfumes` with `notes_include` lists perfumes built around notes.
|
|
164
|
+
|
|
165
|
+
**Can an AI assistant use it directly?**
|
|
166
|
+
Yes, through the 10ML Perfume Finder MCP server (`https://mcp.10ml.com/mcp`, or `npx -y perfume-mcp` / `uvx perfume-mcp`).
|
|
167
|
+
|
|
168
|
+
**What do I get for a perfume?**
|
|
169
|
+
House, launch year, audience, concentration, family, accords, the note pyramid (top, heart, base), seasons and occasions, an editorial character line and links to 10ml.com guides.
|
|
170
|
+
|
|
171
|
+
## Cite as
|
|
172
|
+
|
|
173
|
+
Cite the answers as "the 10ML Fragrance Index", curated by the fragrance editors of [10ML](https://10ml.com).
|
|
174
|
+
|
|
175
|
+
[mcp.10ml.com](https://mcp.10ml.com) · [10ml.com/perfume-api](https://10ml.com/perfume-api) · [Privacy](https://mcp.10ml.com/privacy) · [Terms](https://mcp.10ml.com/terms) · contact@10ml.com
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "perfume-api"
|
|
7
|
+
version = "2.4.0"
|
|
8
|
+
description = "Free perfume API client: perfumes, note pyramids, accords, similar scents, notes and houses from the 10ML Fragrance Index. No API key, JSON over HTTPS."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "10ML Team", email = "contact@10ml.com" }]
|
|
13
|
+
keywords = ["perfume", "fragrance", "cologne", "perfume-api", "fragrance-api", "perfume-mcp", "fragrance-mcp", "mcp", "mcp-server", "model-context-protocol", "perfume-notes", "fragrance-notes", "scent", "eau-de-parfum", "perfume-recommendation", "similar-perfumes", "claude", "chatgpt", "cursor"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://mcp.10ml.com"
|
|
26
|
+
Documentation = "https://mcp.10ml.com"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/perfume_api"]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""perfume-api: a small typed client for the free 10ML Fragrance Index REST API (https://mcp.10ml.com/api/v1).
|
|
2
|
+
|
|
3
|
+
No API key and no dependencies (standard library only). Parameter and response field names are exactly the
|
|
4
|
+
API's; see https://mcp.10ml.com/openapi.json.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from typing import Any, Callable, Mapping, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .types import (
|
|
16
|
+
ExplainNoteResponse,
|
|
17
|
+
FindPerfumeAlternativesResponse,
|
|
18
|
+
FindSimilarPerfumesResponse,
|
|
19
|
+
GetHouseResponse,
|
|
20
|
+
GetPerfumeResponse,
|
|
21
|
+
ListHousesResponse,
|
|
22
|
+
Locale,
|
|
23
|
+
Perfume,
|
|
24
|
+
ResponseFormat,
|
|
25
|
+
SearchGuidesResponse,
|
|
26
|
+
SearchPerfumesResponse,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "2.4.0"
|
|
30
|
+
DEFAULT_BASE_URL = "https://mcp.10ml.com/api/v1"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DEFAULT_BASE_URL",
|
|
34
|
+
"PerfumeApi",
|
|
35
|
+
"PerfumeApiError",
|
|
36
|
+
"compare_perfumes",
|
|
37
|
+
"explain_note",
|
|
38
|
+
"get_house",
|
|
39
|
+
"get_perfume",
|
|
40
|
+
"list_houses",
|
|
41
|
+
"perfume_alternatives",
|
|
42
|
+
"search_guides",
|
|
43
|
+
"search_perfumes",
|
|
44
|
+
"similar_perfumes",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# A transport takes (url, headers, timeout) and returns (status, body_bytes); tests replace it.
|
|
48
|
+
Transport = Callable[[str, Mapping[str, str], float], "tuple[int, bytes]"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PerfumeApiError(Exception):
|
|
52
|
+
"""Raised for every non-2xx answer except 404 (a 404 body is returned: it carries ``found: False``)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, status: int, code: str, message: str, body: Any = None) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.status = status
|
|
57
|
+
self.code = code
|
|
58
|
+
self.message = message
|
|
59
|
+
self.body = body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _urllib_transport(url: str, headers: Mapping[str, str], timeout: float) -> "tuple[int, bytes]":
|
|
63
|
+
request = urllib.request.Request(url, headers=dict(headers))
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(request, timeout=timeout) as res: # noqa: S310 (https base URL)
|
|
66
|
+
return res.status, res.read()
|
|
67
|
+
except urllib.error.HTTPError as err:
|
|
68
|
+
return err.code, err.read()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _query_value(value: Any) -> str:
|
|
72
|
+
if isinstance(value, bool):
|
|
73
|
+
return "true" if value else "false"
|
|
74
|
+
if isinstance(value, (list, tuple)):
|
|
75
|
+
return ",".join(str(v) for v in value)
|
|
76
|
+
return str(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _notes_of(perfume: Perfume) -> "list[str]":
|
|
80
|
+
notes = {n.lower() for n in perfume["key_notes"]}
|
|
81
|
+
pyramid = perfume.get("pyramid")
|
|
82
|
+
if pyramid:
|
|
83
|
+
notes.update(n.lower() for n in [*pyramid["top"], *pyramid["heart"], *pyramid["base"]])
|
|
84
|
+
return sorted(notes)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PerfumeApi:
|
|
88
|
+
"""Client for the 10ML Fragrance Index API.
|
|
89
|
+
|
|
90
|
+
>>> api = PerfumeApi()
|
|
91
|
+
>>> api.search_perfumes(q="fresh citrus summer cologne for men", limit=3)["results"][0]["name"]
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
97
|
+
*,
|
|
98
|
+
timeout: float = 15.0,
|
|
99
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
100
|
+
transport: Optional[Transport] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.base_url = base_url.rstrip("/")
|
|
103
|
+
self.timeout = timeout
|
|
104
|
+
self.headers = {"accept": "application/json", "user-agent": f"perfume-api-python/{__version__}", **(headers or {})}
|
|
105
|
+
self._transport = transport or _urllib_transport
|
|
106
|
+
|
|
107
|
+
def _get(self, path: str, query: Optional[Mapping[str, Any]] = None) -> Any:
|
|
108
|
+
pairs = [(k, _query_value(v)) for k, v in (query or {}).items() if v is not None]
|
|
109
|
+
url = f"{self.base_url}{path}" + (f"?{urllib.parse.urlencode(pairs)}" if pairs else "")
|
|
110
|
+
status, raw = self._transport(url, self.headers, self.timeout)
|
|
111
|
+
try:
|
|
112
|
+
body = json.loads(raw) if raw else None
|
|
113
|
+
except ValueError:
|
|
114
|
+
body = raw.decode("utf-8", "replace")
|
|
115
|
+
if 200 <= status < 300 or status == 404:
|
|
116
|
+
return body
|
|
117
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
118
|
+
err = err if isinstance(err, dict) else {}
|
|
119
|
+
raise PerfumeApiError(status, err.get("code", "http_error"), err.get("message", f"HTTP {status}"), body)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _seg(value: str) -> str:
|
|
123
|
+
return urllib.parse.quote(value, safe="")
|
|
124
|
+
|
|
125
|
+
def search_perfumes(
|
|
126
|
+
self,
|
|
127
|
+
q: Optional[str] = None,
|
|
128
|
+
*,
|
|
129
|
+
house: Optional[str] = None,
|
|
130
|
+
notes_include: Optional[Sequence[str] | str] = None,
|
|
131
|
+
notes_exclude: Optional[Sequence[str] | str] = None,
|
|
132
|
+
accords: Optional[Sequence[str] | str] = None,
|
|
133
|
+
family: Optional[str] = None,
|
|
134
|
+
gender: Optional[str] = None,
|
|
135
|
+
season: Optional[str] = None,
|
|
136
|
+
occasion: Optional[str] = None,
|
|
137
|
+
style: Optional[str] = None,
|
|
138
|
+
iconic: Optional[bool] = None,
|
|
139
|
+
limit: Optional[int] = None,
|
|
140
|
+
cursor: Optional[str] = None,
|
|
141
|
+
format: Optional[ResponseFormat] = None,
|
|
142
|
+
locale: Optional[Locale] = None,
|
|
143
|
+
) -> SearchPerfumesResponse:
|
|
144
|
+
"""Search perfumes by free text, house, notes, accords, family, audience, season, occasion or style."""
|
|
145
|
+
return self._get("/perfumes", {
|
|
146
|
+
"q": q, "house": house, "notes_include": notes_include, "notes_exclude": notes_exclude, "accords": accords,
|
|
147
|
+
"family": family, "gender": gender, "season": season, "occasion": occasion, "style": style, "iconic": iconic,
|
|
148
|
+
"limit": limit, "cursor": cursor, "format": format, "locale": locale,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
def get_perfume(self, id: str, *, house: Optional[str] = None, format: Optional[ResponseFormat] = None, locale: Optional[Locale] = None) -> GetPerfumeResponse:
|
|
152
|
+
"""The full profile of one perfume, by name ("Baccarat Rouge 540") or id. Unknown names return ``found: False`` with suggestions."""
|
|
153
|
+
return self._get(f"/perfumes/{self._seg(id)}", {"house": house, "format": format, "locale": locale})
|
|
154
|
+
|
|
155
|
+
def similar_perfumes(
|
|
156
|
+
self,
|
|
157
|
+
perfume: Optional[str] = None,
|
|
158
|
+
*,
|
|
159
|
+
house: Optional[str] = None,
|
|
160
|
+
notes: Optional[Sequence[str] | str] = None,
|
|
161
|
+
accords: Optional[Sequence[str] | str] = None,
|
|
162
|
+
steer: Optional[str] = None,
|
|
163
|
+
gender: Optional[str] = None,
|
|
164
|
+
other_houses_only: Optional[bool] = None,
|
|
165
|
+
limit: Optional[int] = None,
|
|
166
|
+
cursor: Optional[str] = None,
|
|
167
|
+
format: Optional[ResponseFormat] = None,
|
|
168
|
+
locale: Optional[Locale] = None,
|
|
169
|
+
) -> FindSimilarPerfumesResponse:
|
|
170
|
+
"""Perfumes that smell like one you name, or like a list of notes and accords."""
|
|
171
|
+
return self._get("/similar", {
|
|
172
|
+
"perfume": perfume, "house": house, "notes": notes, "accords": accords, "steer": steer, "gender": gender,
|
|
173
|
+
"other_houses_only": other_houses_only, "limit": limit, "cursor": cursor, "format": format, "locale": locale,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
def perfume_alternatives(
|
|
177
|
+
self,
|
|
178
|
+
perfume: str,
|
|
179
|
+
*,
|
|
180
|
+
house: Optional[str] = None,
|
|
181
|
+
closeness: Optional[str] = None,
|
|
182
|
+
limit: Optional[int] = None,
|
|
183
|
+
cursor: Optional[str] = None,
|
|
184
|
+
locale: Optional[Locale] = None,
|
|
185
|
+
) -> FindPerfumeAlternativesResponse:
|
|
186
|
+
"""The editors' alternatives to a famous perfume, from other houses."""
|
|
187
|
+
return self._get("/alternatives", {"perfume": perfume, "house": house, "closeness": closeness, "limit": limit, "cursor": cursor, "locale": locale})
|
|
188
|
+
|
|
189
|
+
def explain_note(self, id: str, *, format: Optional[ResponseFormat] = None, locale: Optional[Locale] = None) -> ExplainNoteResponse:
|
|
190
|
+
"""What a fragrance note smells like, what it pairs with and where it leads."""
|
|
191
|
+
return self._get(f"/notes/{self._seg(id)}", {"format": format, "locale": locale})
|
|
192
|
+
|
|
193
|
+
def get_house(self, id: str, *, format: Optional[ResponseFormat] = None, locale: Optional[Locale] = None) -> GetHouseResponse:
|
|
194
|
+
"""A perfume house: country, style, what it is known for, signature perfumes."""
|
|
195
|
+
return self._get(f"/houses/{self._seg(id)}", {"format": format, "locale": locale})
|
|
196
|
+
|
|
197
|
+
def list_houses(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
type: Optional[str] = None,
|
|
201
|
+
country: Optional[str] = None,
|
|
202
|
+
query: Optional[str] = None,
|
|
203
|
+
limit: Optional[int] = None,
|
|
204
|
+
cursor: Optional[str] = None,
|
|
205
|
+
locale: Optional[Locale] = None,
|
|
206
|
+
) -> ListHousesResponse:
|
|
207
|
+
"""Browse perfume houses by type, country or name fragment."""
|
|
208
|
+
return self._get("/houses", {"type": type, "country": country, "query": query, "limit": limit, "cursor": cursor, "locale": locale})
|
|
209
|
+
|
|
210
|
+
def search_guides(
|
|
211
|
+
self,
|
|
212
|
+
q: Optional[str] = None,
|
|
213
|
+
*,
|
|
214
|
+
type: Optional[str] = None,
|
|
215
|
+
limit: Optional[int] = None,
|
|
216
|
+
cursor: Optional[str] = None,
|
|
217
|
+
locale: Optional[Locale] = None,
|
|
218
|
+
) -> SearchGuidesResponse:
|
|
219
|
+
"""10ML perfume guides and articles to read or cite."""
|
|
220
|
+
return self._get("/guides", {"q": q, "type": type, "limit": limit, "cursor": cursor, "locale": locale})
|
|
221
|
+
|
|
222
|
+
def compare_perfumes(self, names: Sequence[str], *, format: Optional[ResponseFormat] = None, locale: Optional[Locale] = None) -> "dict[str, Any]":
|
|
223
|
+
"""Compare 2 to 4 perfumes.
|
|
224
|
+
|
|
225
|
+
The REST API has no compare route, so this fetches each profile with :meth:`get_perfume` and diffs notes and
|
|
226
|
+
accords locally. Returns ``perfumes``, ``not_found``, ``same_family``, ``shared_notes``, ``shared_accords`` and
|
|
227
|
+
``only`` (per perfume id: the notes and accords no other compared perfume has).
|
|
228
|
+
"""
|
|
229
|
+
if not 2 <= len(names) <= 4:
|
|
230
|
+
raise ValueError("compare_perfumes takes 2 to 4 perfumes")
|
|
231
|
+
with ThreadPoolExecutor(max_workers=len(names)) as pool:
|
|
232
|
+
answers = list(pool.map(lambda n: self.get_perfume(n, format=format, locale=locale), names))
|
|
233
|
+
perfumes: "list[Perfume]" = []
|
|
234
|
+
not_found: "list[str]" = []
|
|
235
|
+
for name, answer in zip(names, answers):
|
|
236
|
+
if answer.get("found") and answer.get("perfume"):
|
|
237
|
+
perfumes.append(answer["perfume"])
|
|
238
|
+
else:
|
|
239
|
+
not_found.append(name)
|
|
240
|
+
notes = [_notes_of(p) for p in perfumes]
|
|
241
|
+
accords = [[a.lower() for a in p["accords"]] for p in perfumes]
|
|
242
|
+
|
|
243
|
+
def in_all(lists: "list[list[str]]") -> "list[str]":
|
|
244
|
+
return [x for x in lists[0] if all(x in other for other in lists)] if len(lists) > 1 else []
|
|
245
|
+
|
|
246
|
+
only: "dict[str, dict[str, list[str]]]" = {}
|
|
247
|
+
for i, p in enumerate(perfumes):
|
|
248
|
+
other_notes = {n for j, lst in enumerate(notes) if j != i for n in lst}
|
|
249
|
+
other_accords = {a for j, lst in enumerate(accords) if j != i for a in lst}
|
|
250
|
+
only[p["id"]] = {
|
|
251
|
+
"notes": [n for n in notes[i] if n not in other_notes],
|
|
252
|
+
"accords": [a for a in accords[i] if a not in other_accords],
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
"perfumes": perfumes,
|
|
256
|
+
"not_found": not_found,
|
|
257
|
+
"same_family": len(perfumes) > 1 and all(p["family"] == perfumes[0]["family"] for p in perfumes),
|
|
258
|
+
"shared_notes": in_all(notes),
|
|
259
|
+
"shared_accords": in_all(accords),
|
|
260
|
+
"only": only,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_default = PerfumeApi()
|
|
265
|
+
search_perfumes = _default.search_perfumes
|
|
266
|
+
get_perfume = _default.get_perfume
|
|
267
|
+
similar_perfumes = _default.similar_perfumes
|
|
268
|
+
perfume_alternatives = _default.perfume_alternatives
|
|
269
|
+
explain_note = _default.explain_note
|
|
270
|
+
get_house = _default.get_house
|
|
271
|
+
list_houses = _default.list_houses
|
|
272
|
+
search_guides = _default.search_guides
|
|
273
|
+
compare_perfumes = _default.compare_perfumes
|
|
File without changes
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Generated by scripts/gen-types.mjs from spec/openapi.json (10ML Fragrance Index API 2.3.4). Do not edit by hand."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Literal, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _PerfumeRequired(TypedDict):
|
|
8
|
+
id: str
|
|
9
|
+
name: str
|
|
10
|
+
house: str
|
|
11
|
+
gender: Literal["men", "women", "unisex"]
|
|
12
|
+
family: str
|
|
13
|
+
accords: list[str]
|
|
14
|
+
key_notes: list[str]
|
|
15
|
+
character: str
|
|
16
|
+
links: list[Link]
|
|
17
|
+
profile: Literal["full", "core"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Perfume(_PerfumeRequired, total=False):
|
|
21
|
+
year: float
|
|
22
|
+
concentration: str
|
|
23
|
+
pyramid: Pyramid
|
|
24
|
+
seasons: list[str]
|
|
25
|
+
occasions: list[str]
|
|
26
|
+
styles: list[str]
|
|
27
|
+
longevity: float
|
|
28
|
+
projection: float
|
|
29
|
+
perfumers: list[str]
|
|
30
|
+
other_versions: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Link(TypedDict):
|
|
34
|
+
type: Literal["try", "guide", "note"]
|
|
35
|
+
title: str
|
|
36
|
+
url: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Pyramid(TypedDict):
|
|
40
|
+
top: list[str]
|
|
41
|
+
heart: list[str]
|
|
42
|
+
base: list[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _SearchPerfumesResponseRequired(TypedDict):
|
|
46
|
+
total: float
|
|
47
|
+
results: list[Perfume]
|
|
48
|
+
filters: dict[str, str]
|
|
49
|
+
relaxed: bool
|
|
50
|
+
source: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SearchPerfumesResponse(_SearchPerfumesResponseRequired, total=False):
|
|
54
|
+
next_cursor: str
|
|
55
|
+
message: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _GetPerfumeResponseHouseInfoRequired(TypedDict):
|
|
59
|
+
name: str
|
|
60
|
+
type: str
|
|
61
|
+
style: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GetPerfumeResponseHouseInfo(_GetPerfumeResponseHouseInfoRequired, total=False):
|
|
65
|
+
country: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GetPerfumeResponseSimilarItem(TypedDict):
|
|
69
|
+
id: str
|
|
70
|
+
name: str
|
|
71
|
+
house: str
|
|
72
|
+
closeness: Literal["very close", "similar vibe", "same spirit"]
|
|
73
|
+
shared_notes: list[str]
|
|
74
|
+
url: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _GetPerfumeResponseAlternativesItemRequired(TypedDict):
|
|
78
|
+
name: str
|
|
79
|
+
closeness: Literal["very close", "similar vibe", "same spirit"]
|
|
80
|
+
why: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class GetPerfumeResponseAlternativesItem(_GetPerfumeResponseAlternativesItemRequired, total=False):
|
|
84
|
+
house: str
|
|
85
|
+
id: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _GetPerfumeResponseRequired(TypedDict):
|
|
89
|
+
found: bool
|
|
90
|
+
exact: bool
|
|
91
|
+
source: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class GetPerfumeResponse(_GetPerfumeResponseRequired, total=False):
|
|
95
|
+
perfume: Perfume
|
|
96
|
+
house_info: GetPerfumeResponseHouseInfo
|
|
97
|
+
similar: list[GetPerfumeResponseSimilarItem]
|
|
98
|
+
alternatives: list[GetPerfumeResponseAlternativesItem]
|
|
99
|
+
guides: list[Link]
|
|
100
|
+
suggestions: list[Perfume]
|
|
101
|
+
message: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _FindSimilarPerfumesResponseTargetRequired(TypedDict):
|
|
105
|
+
name: str
|
|
106
|
+
house: str | None
|
|
107
|
+
in_index: bool
|
|
108
|
+
notes: list[str]
|
|
109
|
+
accords: list[str]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FindSimilarPerfumesResponseTarget(_FindSimilarPerfumesResponseTargetRequired, total=False):
|
|
113
|
+
id: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FindSimilarPerfumesResponseResultsItem(TypedDict):
|
|
117
|
+
perfume: Perfume
|
|
118
|
+
closeness: Literal["very close", "similar vibe", "same spirit"]
|
|
119
|
+
shared_notes: list[str]
|
|
120
|
+
shared_accords: list[str]
|
|
121
|
+
why: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _FindSimilarPerfumesResponseRequired(TypedDict):
|
|
125
|
+
total: float
|
|
126
|
+
target: FindSimilarPerfumesResponseTarget
|
|
127
|
+
results: list[FindSimilarPerfumesResponseResultsItem]
|
|
128
|
+
source: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FindSimilarPerfumesResponse(_FindSimilarPerfumesResponseRequired, total=False):
|
|
132
|
+
next_cursor: str
|
|
133
|
+
message: str
|
|
134
|
+
unrecognised_notes: list[str]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _FindPerfumeAlternativesResponseOriginalRequired(TypedDict):
|
|
138
|
+
name: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class FindPerfumeAlternativesResponseOriginal(_FindPerfumeAlternativesResponseOriginalRequired, total=False):
|
|
142
|
+
house: str
|
|
143
|
+
id: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _FindPerfumeAlternativesResponseAlternativesItemRequired(TypedDict):
|
|
147
|
+
name: str
|
|
148
|
+
closeness: Literal["very close", "similar vibe", "same spirit"]
|
|
149
|
+
why: str
|
|
150
|
+
source: Literal["editors", "similarity"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class FindPerfumeAlternativesResponseAlternativesItem(_FindPerfumeAlternativesResponseAlternativesItemRequired, total=False):
|
|
154
|
+
house: str
|
|
155
|
+
id: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _FindPerfumeAlternativesResponseRequired(TypedDict):
|
|
159
|
+
total: float
|
|
160
|
+
original: FindPerfumeAlternativesResponseOriginal
|
|
161
|
+
alternatives: list[FindPerfumeAlternativesResponseAlternativesItem]
|
|
162
|
+
links: list[Link]
|
|
163
|
+
source: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class FindPerfumeAlternativesResponse(_FindPerfumeAlternativesResponseRequired, total=False):
|
|
167
|
+
next_cursor: str
|
|
168
|
+
message: str
|
|
169
|
+
guide: Link
|
|
170
|
+
other_matches: list[str]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _ExplainNoteResponseNoteRequired(TypedDict):
|
|
174
|
+
id: str
|
|
175
|
+
name: str
|
|
176
|
+
family: str
|
|
177
|
+
origin: str
|
|
178
|
+
smells_like: str
|
|
179
|
+
mood: str
|
|
180
|
+
also_called: list[str]
|
|
181
|
+
pairs_with: list[str]
|
|
182
|
+
similar_notes: list[str]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ExplainNoteResponseNote(_ExplainNoteResponseNoteRequired, total=False):
|
|
186
|
+
description: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _ExplainNoteResponseRequired(TypedDict):
|
|
190
|
+
found: bool
|
|
191
|
+
source: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ExplainNoteResponse(_ExplainNoteResponseRequired, total=False):
|
|
195
|
+
note: ExplainNoteResponseNote
|
|
196
|
+
famous_perfumes: list[Perfume]
|
|
197
|
+
links: list[Link]
|
|
198
|
+
suggestions: list[str]
|
|
199
|
+
message: str
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _ListHousesResponseHousesItemRequired(TypedDict):
|
|
203
|
+
id: str
|
|
204
|
+
name: str
|
|
205
|
+
type: str
|
|
206
|
+
perfumes_in_index: float
|
|
207
|
+
known_for: list[str]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ListHousesResponseHousesItem(_ListHousesResponseHousesItemRequired, total=False):
|
|
211
|
+
country: str
|
|
212
|
+
founded: float
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _ListHousesResponseRequired(TypedDict):
|
|
216
|
+
total: float
|
|
217
|
+
houses: list[ListHousesResponseHousesItem]
|
|
218
|
+
source: str
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ListHousesResponse(_ListHousesResponseRequired, total=False):
|
|
222
|
+
next_cursor: str
|
|
223
|
+
message: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _GetHouseResponseHouseRequired(TypedDict):
|
|
227
|
+
id: str
|
|
228
|
+
name: str
|
|
229
|
+
type: str
|
|
230
|
+
perfumes_in_index: float
|
|
231
|
+
known_for: list[str]
|
|
232
|
+
style: str
|
|
233
|
+
aliases: list[str]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class GetHouseResponseHouse(_GetHouseResponseHouseRequired, total=False):
|
|
237
|
+
country: str
|
|
238
|
+
founded: float
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _GetHouseResponseRequired(TypedDict):
|
|
242
|
+
found: bool
|
|
243
|
+
source: str
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class GetHouseResponse(_GetHouseResponseRequired, total=False):
|
|
247
|
+
house: GetHouseResponseHouse
|
|
248
|
+
signature_perfumes: list[Perfume]
|
|
249
|
+
links: list[Link]
|
|
250
|
+
suggestions: list[str]
|
|
251
|
+
message: str
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class SearchGuidesResponseGuidesItem(TypedDict):
|
|
255
|
+
title: str
|
|
256
|
+
type: str
|
|
257
|
+
topics: list[str]
|
|
258
|
+
url: str
|
|
259
|
+
translated: bool
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class _SearchGuidesResponseRequired(TypedDict):
|
|
263
|
+
total: float
|
|
264
|
+
guides: list[SearchGuidesResponseGuidesItem]
|
|
265
|
+
source: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class SearchGuidesResponse(_SearchGuidesResponseRequired, total=False):
|
|
269
|
+
next_cursor: str
|
|
270
|
+
message: str
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
Locale = Literal["en", "de", "fr", "es", "it", "ro", "el", "hu", "cs", "pl"]
|
|
274
|
+
ResponseFormat = Literal["concise", "detailed"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Live smoke test against https://mcp.10ml.com/api/v1 (needs internet). Run: PYTHONPATH=src python tests/smoke.py"""
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from perfume_api import (
|
|
5
|
+
PerfumeApiError, compare_perfumes, explain_note, get_house, get_perfume, list_houses,
|
|
6
|
+
perfume_alternatives, search_guides, search_perfumes, similar_perfumes,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def step(name, fn):
|
|
11
|
+
t = time.time()
|
|
12
|
+
fn()
|
|
13
|
+
print(f"ok {name} ({int((time.time() - t) * 1000)} ms)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def invalid():
|
|
17
|
+
try:
|
|
18
|
+
search_perfumes(limit=99)
|
|
19
|
+
except PerfumeApiError as err:
|
|
20
|
+
assert err.status == 400, err.status
|
|
21
|
+
return
|
|
22
|
+
raise AssertionError("expected PerfumeApiError")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
step("search_perfumes", lambda: (lambda r: (r["total"] > 0 and len(r["results"]) == 3) or 1 / 0)(search_perfumes("fresh citrus summer cologne for men", limit=3)))
|
|
26
|
+
step("search_perfumes notes_include list + gender", lambda: search_perfumes(notes_include=["vanilla", "oud"], gender="men", limit=2)["results"])
|
|
27
|
+
step("get_perfume", lambda: (lambda r: r["found"] or 1 / 0)(get_perfume("Baccarat Rouge 540")))
|
|
28
|
+
step("get_perfume unknown -> found False", lambda: (get_perfume("zzzzqq")["found"] is False) or 1 / 0)
|
|
29
|
+
step("similar_perfumes", lambda: (similar_perfumes("Sauvage", limit=3)["results"]) or 1 / 0)
|
|
30
|
+
step("similar_perfumes from notes", lambda: (similar_perfumes(notes=["saffron", "cedar"], limit=2)["results"]) or 1 / 0)
|
|
31
|
+
step("perfume_alternatives", lambda: (perfume_alternatives("Baccarat Rouge 540", limit=3)["alternatives"]) or 1 / 0)
|
|
32
|
+
step("explain_note", lambda: explain_note("ambroxan")["found"] or 1 / 0)
|
|
33
|
+
step("get_house", lambda: get_house("Creed")["found"] or 1 / 0)
|
|
34
|
+
step("list_houses", lambda: (len(list_houses(type="niche", limit=3)["houses"]) == 3) or 1 / 0)
|
|
35
|
+
step("search_guides", lambda: (search_guides("layering", limit=2)["guides"]) or 1 / 0)
|
|
36
|
+
step("compare_perfumes", lambda: (len(compare_perfumes(["Creed Aventus", "Dior Sauvage"])["perfumes"]) == 2) or 1 / 0)
|
|
37
|
+
step("invalid request raises PerfumeApiError", invalid)
|
|
38
|
+
print("perfume-api live smoke: all passed")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Unit tests with a mocked transport. Run: python -m unittest discover -s tests -v (from this folder, with PYTHONPATH=src)"""
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
5
|
+
|
|
6
|
+
from perfume_api import DEFAULT_BASE_URL, PerfumeApi, PerfumeApiError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make(handler):
|
|
10
|
+
calls = []
|
|
11
|
+
|
|
12
|
+
def transport(url, headers, timeout):
|
|
13
|
+
calls.append({"url": url, "headers": dict(headers), "timeout": timeout})
|
|
14
|
+
status, body = handler(url)
|
|
15
|
+
return status, (body if isinstance(body, bytes) else json.dumps(body).encode())
|
|
16
|
+
|
|
17
|
+
return PerfumeApi(transport=transport), calls
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def perfume(id, family, key_notes, accords):
|
|
21
|
+
return {"id": id, "name": id, "house": "H", "gender": "unisex", "family": family, "accords": accords, "key_notes": key_notes, "character": "x", "links": [], "profile": "core"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClientTest(unittest.TestCase):
|
|
25
|
+
def test_default_base_url(self):
|
|
26
|
+
self.assertEqual(DEFAULT_BASE_URL, "https://mcp.10ml.com/api/v1")
|
|
27
|
+
|
|
28
|
+
def test_search_builds_query(self):
|
|
29
|
+
api, calls = make(lambda u: (200, {"total": 0, "results": [], "filters": {}, "relaxed": False, "source": "s"}))
|
|
30
|
+
api.search_perfumes("fresh citrus", notes_include=["vanilla", "oud"], gender="men", limit=3, iconic=True)
|
|
31
|
+
u = urlparse(calls[0]["url"])
|
|
32
|
+
q = parse_qs(u.query)
|
|
33
|
+
self.assertEqual(u.path, "/api/v1/perfumes")
|
|
34
|
+
self.assertEqual(q["q"], ["fresh citrus"])
|
|
35
|
+
self.assertEqual(q["notes_include"], ["vanilla,oud"])
|
|
36
|
+
self.assertEqual(q["limit"], ["3"])
|
|
37
|
+
self.assertEqual(q["iconic"], ["true"])
|
|
38
|
+
self.assertNotIn("house", q)
|
|
39
|
+
self.assertEqual(calls[0]["headers"]["accept"], "application/json")
|
|
40
|
+
|
|
41
|
+
def test_path_ids_encoded_and_base_url_configurable(self):
|
|
42
|
+
api, calls = make(lambda u: (200, {"found": True, "exact": True, "source": "s"}))
|
|
43
|
+
api.base_url = "https://example.test/v1"
|
|
44
|
+
api.get_perfume("Baccarat Rouge 540", locale="de")
|
|
45
|
+
api.explain_note("ambroxan")
|
|
46
|
+
api.get_house("MFK")
|
|
47
|
+
self.assertEqual(calls[0]["url"], "https://example.test/v1/perfumes/Baccarat%20Rouge%20540?locale=de")
|
|
48
|
+
self.assertEqual(calls[1]["url"], "https://example.test/v1/notes/ambroxan")
|
|
49
|
+
self.assertEqual(calls[2]["url"], "https://example.test/v1/houses/MFK")
|
|
50
|
+
|
|
51
|
+
def test_each_method_calls_its_route(self):
|
|
52
|
+
api, calls = make(lambda u: (200, {}))
|
|
53
|
+
api.similar_perfumes("Sauvage", steer="fresher")
|
|
54
|
+
api.perfume_alternatives("Delina")
|
|
55
|
+
api.list_houses(type="niche", limit=2)
|
|
56
|
+
api.search_guides("layering")
|
|
57
|
+
self.assertEqual([urlparse(c["url"]).path for c in calls], ["/api/v1/similar", "/api/v1/alternatives", "/api/v1/houses", "/api/v1/guides"])
|
|
58
|
+
|
|
59
|
+
def test_404_returns_body(self):
|
|
60
|
+
api, _ = make(lambda u: (404, {"found": False, "suggestions": [], "message": "no", "source": "s"}))
|
|
61
|
+
self.assertFalse(api.get_house("nope")["found"])
|
|
62
|
+
|
|
63
|
+
def test_400_raises(self):
|
|
64
|
+
api, _ = make(lambda u: (400, {"error": {"code": "invalid_request", "message": "limit: too big"}}))
|
|
65
|
+
with self.assertRaises(PerfumeApiError) as ctx:
|
|
66
|
+
api.search_perfumes(limit=99)
|
|
67
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code, str(ctx.exception)), (400, "invalid_request", "limit: too big"))
|
|
68
|
+
|
|
69
|
+
def test_non_json_server_error_raises(self):
|
|
70
|
+
api, _ = make(lambda u: (502, b"bad gateway"))
|
|
71
|
+
with self.assertRaises(PerfumeApiError) as ctx:
|
|
72
|
+
api.list_houses()
|
|
73
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (502, "http_error"))
|
|
74
|
+
|
|
75
|
+
def test_compare(self):
|
|
76
|
+
data = {
|
|
77
|
+
"a": perfume("a", "Woody", ["saffron", "cedar", "amber"], ["woody", "sweet"]),
|
|
78
|
+
"b": perfume("b", "Woody", ["Saffron", "jasmine"], ["woody", "fresh"]),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def handler(url):
|
|
82
|
+
pid = url.split("?")[0].rsplit("/", 1)[1]
|
|
83
|
+
if pid in data:
|
|
84
|
+
return 200, {"found": True, "exact": True, "perfume": data[pid], "source": "s"}
|
|
85
|
+
return 404, {"found": False, "exact": False, "source": "s"}
|
|
86
|
+
|
|
87
|
+
api, _ = make(handler)
|
|
88
|
+
cmp = api.compare_perfumes(["a", "b", "zzz"])
|
|
89
|
+
self.assertEqual(cmp["not_found"], ["zzz"])
|
|
90
|
+
self.assertTrue(cmp["same_family"])
|
|
91
|
+
self.assertEqual(cmp["shared_notes"], ["saffron"])
|
|
92
|
+
self.assertEqual(cmp["shared_accords"], ["woody"])
|
|
93
|
+
self.assertEqual(sorted(cmp["only"]["a"]["notes"]), ["amber", "cedar"])
|
|
94
|
+
self.assertEqual(cmp["only"]["b"], {"notes": ["jasmine"], "accords": ["fresh"]})
|
|
95
|
+
|
|
96
|
+
def test_compare_needs_2_to_4(self):
|
|
97
|
+
api, _ = make(lambda u: (200, {}))
|
|
98
|
+
with self.assertRaises(ValueError):
|
|
99
|
+
api.compare_perfumes(["a"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
unittest.main()
|