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.
@@ -0,0 +1,4 @@
1
+ __pycache__
2
+ *.egg-info
3
+ dist
4
+ .venv
@@ -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
+ [![Official MCP Registry](https://img.shields.io/badge/MCP%20Registry-com.10ml%2Fperfume--finder-002AFF)](https://registry.modelcontextprotocol.io/v0/servers?search=com.10ml/perfume-finder)
25
+ [![PyPI](https://img.shields.io/pypi/v/perfume-api?color=002AFF)](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
+ [![Official MCP Registry](https://img.shields.io/badge/MCP%20Registry-com.10ml%2Fperfume--finder-002AFF)](https://registry.modelcontextprotocol.io/v0/servers?search=com.10ml/perfume-finder)
6
+ [![PyPI](https://img.shields.io/pypi/v/perfume-api?color=002AFF)](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()