unspsc-fr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ .DS_Store
5
+ python/dist/
6
+ *.egg-info/
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: unspsc-fr
3
+ Version: 0.1.0
4
+ Summary: UNSPSC v26 bilingue EN/FR : lookup, hiérarchie et recherche floue sur la nomenclature des marchés publics
5
+ Project-URL: Homepage, https://github.com/codebysaadbouh/unspsc-fr
6
+ Project-URL: Source, https://github.com/codebysaadbouh/unspsc-fr
7
+ Author: Saadbouh
8
+ License-Expression: MIT
9
+ Keywords: bilingual,classification,marchés publics,procurement,unspsc
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: English
13
+ Classifier: Natural Language :: French
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Office/Business
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # unspsc-fr
20
+
21
+ La nomenclature **UNSPSC v26** bilingue EN/FR, requêtable en Python : lookup de codes,
22
+ navigation hiérarchique et recherche plein texte insensible aux accents.
23
+
24
+ Données : 149 849 produits, 21 706 traductions françaises sourcées (gouvernement du
25
+ Canada). Détails, couverture et licences :
26
+ [github.com/codebysaadbouh/unspsc-fr](https://github.com/codebysaadbouh/unspsc-fr).
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install unspsc-fr
32
+ ```
33
+
34
+ La base SQLite (~88 Mo) est téléchargée au premier usage puis mise en cache
35
+ (`~/.cache/unspsc-fr/`). Pour utiliser une base locale : variable d'environnement
36
+ `UNSPSC_FR_DB` ou `UNSPSC(db_path=...)`.
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from unspsc_fr import UNSPSC
42
+
43
+ u = UNSPSC()
44
+
45
+ u.get(10101501)
46
+ # Entry(code=10101501, level='commodity', title_en='Cats', title_fr='Chats', …)
47
+ # .title → 'Chats' (repli automatique sur l'anglais si non traduit)
48
+
49
+ u.search("béton", lang="fr") # plein texte, accents ignorés
50
+ u.search("cemen", lang="en") # préfixe : saisie incrémentale
51
+ u.children() # les 58 segments
52
+ u.children(10101500) # produits d'une classe
53
+ u.ancestors(10101501) # segment > famille > classe
54
+ ```
55
+
56
+ ## CLI
57
+
58
+ ```bash
59
+ unspsc-fr get 10101501
60
+ unspsc-fr search "ciment" --lang fr
61
+ unspsc-fr children 10100000
62
+ unspsc-fr ancestors 10101501 --json
63
+ ```
64
+
65
+ ## Licences
66
+
67
+ Code MIT. Codeset © UNDP (UNSPSC®) ; traductions françaises sous Licence du
68
+ gouvernement ouvert – Canada. Voir le
69
+ [NOTICE](https://github.com/codebysaadbouh/unspsc-fr/blob/main/NOTICE) du dépôt.
@@ -0,0 +1,51 @@
1
+ # unspsc-fr
2
+
3
+ La nomenclature **UNSPSC v26** bilingue EN/FR, requêtable en Python : lookup de codes,
4
+ navigation hiérarchique et recherche plein texte insensible aux accents.
5
+
6
+ Données : 149 849 produits, 21 706 traductions françaises sourcées (gouvernement du
7
+ Canada). Détails, couverture et licences :
8
+ [github.com/codebysaadbouh/unspsc-fr](https://github.com/codebysaadbouh/unspsc-fr).
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install unspsc-fr
14
+ ```
15
+
16
+ La base SQLite (~88 Mo) est téléchargée au premier usage puis mise en cache
17
+ (`~/.cache/unspsc-fr/`). Pour utiliser une base locale : variable d'environnement
18
+ `UNSPSC_FR_DB` ou `UNSPSC(db_path=...)`.
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from unspsc_fr import UNSPSC
24
+
25
+ u = UNSPSC()
26
+
27
+ u.get(10101501)
28
+ # Entry(code=10101501, level='commodity', title_en='Cats', title_fr='Chats', …)
29
+ # .title → 'Chats' (repli automatique sur l'anglais si non traduit)
30
+
31
+ u.search("béton", lang="fr") # plein texte, accents ignorés
32
+ u.search("cemen", lang="en") # préfixe : saisie incrémentale
33
+ u.children() # les 58 segments
34
+ u.children(10101500) # produits d'une classe
35
+ u.ancestors(10101501) # segment > famille > classe
36
+ ```
37
+
38
+ ## CLI
39
+
40
+ ```bash
41
+ unspsc-fr get 10101501
42
+ unspsc-fr search "ciment" --lang fr
43
+ unspsc-fr children 10100000
44
+ unspsc-fr ancestors 10101501 --json
45
+ ```
46
+
47
+ ## Licences
48
+
49
+ Code MIT. Codeset © UNDP (UNSPSC®) ; traductions françaises sous Licence du
50
+ gouvernement ouvert – Canada. Voir le
51
+ [NOTICE](https://github.com/codebysaadbouh/unspsc-fr/blob/main/NOTICE) du dépôt.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "unspsc-fr"
7
+ version = "0.1.0"
8
+ description = "UNSPSC v26 bilingue EN/FR : lookup, hiérarchie et recherche floue sur la nomenclature des marchés publics"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Saadbouh" }]
13
+ keywords = ["unspsc", "procurement", "marchés publics", "classification", "bilingual"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Natural Language :: French",
18
+ "Natural Language :: English",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Office/Business",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/codebysaadbouh/unspsc-fr"
25
+ Source = "https://github.com/codebysaadbouh/unspsc-fr"
26
+
27
+ [project.scripts]
28
+ unspsc-fr = "unspsc_fr.cli:main"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/unspsc_fr"]
@@ -0,0 +1,200 @@
1
+ """unspsc-fr : la nomenclature UNSPSC bilingue EN/FR, requêtable en Python.
2
+
3
+ from unspsc_fr import UNSPSC
4
+
5
+ u = UNSPSC() # télécharge la base au premier usage
6
+ u.get(10101501) # Entry(code=10101501, title='Chats', …)
7
+ u.search("ciment", lang="fr") # recherche plein texte, accents ignorés
8
+ u.children(10101500) # produits de la classe
9
+ u.ancestors(10101501) # segment > famille > classe
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sqlite3
15
+ from dataclasses import dataclass
16
+ from typing import Optional
17
+
18
+ from . import db as _db
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = ["UNSPSC", "Entry", "__version__"]
22
+
23
+ LEVELS = ("segment", "family", "class", "commodity")
24
+ _TABLE = {
25
+ "segment": ("segments", "segment_code"),
26
+ "family": ("families", "family_code"),
27
+ "class": ("classes", "class_code"),
28
+ "commodity": ("commodities", "commodity_code"),
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Entry:
34
+ code: int
35
+ level: str
36
+ title_en: str
37
+ title_fr: Optional[str]
38
+ definition_en: Optional[str] = None
39
+
40
+ @property
41
+ def title(self) -> str:
42
+ """Titre français, repli anglais si non traduit."""
43
+ return self.title_fr or self.title_en
44
+
45
+ @property
46
+ def is_translated(self) -> bool:
47
+ return self.title_fr is not None
48
+
49
+
50
+ def level_of(code: int) -> str:
51
+ """Niveau déduit de la forme du code (XX000000 → segment, etc.).
52
+
53
+ Attention : quelques codes existent à deux niveaux dans le codeset UNDP
54
+ (ex. 26132000 est une classe ET un produit) ; cette fonction retourne le
55
+ niveau le plus haut. ``UNSPSC.get`` accepte un ``level`` explicite.
56
+ """
57
+ if code % 1_000_000 == 0:
58
+ return "segment"
59
+ if code % 10_000 == 0:
60
+ return "family"
61
+ if code % 100 == 0:
62
+ return "class"
63
+ return "commodity"
64
+
65
+
66
+ def parent_code(code: int, level: str) -> Optional[int]:
67
+ if level == "segment":
68
+ return None
69
+ divisor = {"family": 1_000_000, "class": 10_000, "commodity": 100}[level]
70
+ return code // divisor * divisor
71
+
72
+
73
+ class UNSPSC:
74
+ def __init__(self, db_path: "str | os.PathLike | None" = None):
75
+ self._con: sqlite3.Connection = _db.connect(db_path)
76
+
77
+ # -- lookup ---------------------------------------------------------
78
+
79
+ def get(self, code: int, level: Optional[str] = None) -> Optional[Entry]:
80
+ """L'entrée pour un code, ou None. ``level`` lève l'ambiguïté des
81
+ rares codes présents à deux niveaux."""
82
+ code = int(code)
83
+ for lv in [level] if level else self._levels_for(code):
84
+ table, pk = _TABLE[lv]
85
+ row = self._con.execute(
86
+ f"""
87
+ SELECT t.{pk} AS code, t.title_en, t.definition_en, fr.title AS title_fr
88
+ FROM {table} t
89
+ LEFT JOIN translations fr
90
+ ON fr.code = t.{pk} AND fr.level = ? AND fr.lang = 'fr'
91
+ WHERE t.{pk} = ?
92
+ """,
93
+ (lv, code),
94
+ ).fetchone()
95
+ if row:
96
+ return Entry(row["code"], lv, row["title_en"], row["title_fr"],
97
+ row["definition_en"])
98
+ return None
99
+
100
+ def _levels_for(self, code: int) -> list[str]:
101
+ first = level_of(code)
102
+ # essaie d'abord le niveau canonique, puis les niveaux plus profonds
103
+ # pour les codes ambigus
104
+ return [first] + [lv for lv in LEVELS[LEVELS.index(first) + 1:]]
105
+
106
+ # -- hiérarchie -----------------------------------------------------
107
+
108
+ def children(self, code: Optional[int] = None) -> list[Entry]:
109
+ """Les enfants directs d'un code ; sans argument, les 58 segments."""
110
+ if code is None:
111
+ return self._select("segment", "1=1", ())
112
+ level = level_of(int(code))
113
+ child = {"segment": "family", "family": "class", "class": "commodity"}.get(level)
114
+ if child is None:
115
+ return []
116
+ table, pk = _TABLE[child]
117
+ parent_col = _TABLE[level][1]
118
+ return self._select(child, f"t.{parent_col} = ?", (int(code),))
119
+
120
+ def ancestors(self, code: int, level: Optional[str] = None) -> list[Entry]:
121
+ """Du segment jusqu'au parent direct (exclut le code lui-même)."""
122
+ entry = self.get(code, level)
123
+ if entry is None:
124
+ return []
125
+ chain: list[Entry] = []
126
+ current = parent_code(entry.code, entry.level)
127
+ current_level = entry.level
128
+ while current is not None:
129
+ current_level = LEVELS[LEVELS.index(current_level) - 1]
130
+ parent = self.get(current, current_level)
131
+ if parent:
132
+ chain.append(parent)
133
+ current = parent_code(current, current_level)
134
+ return list(reversed(chain))
135
+
136
+ def _select(self, level: str, where: str, params: tuple) -> list[Entry]:
137
+ table, pk = _TABLE[level]
138
+ rows = self._con.execute(
139
+ f"""
140
+ SELECT t.{pk} AS code, t.title_en, t.definition_en, fr.title AS title_fr
141
+ FROM {table} t
142
+ LEFT JOIN translations fr
143
+ ON fr.code = t.{pk} AND fr.level = ? AND fr.lang = 'fr'
144
+ WHERE {where} ORDER BY t.{pk}
145
+ """,
146
+ (level, *params),
147
+ ).fetchall()
148
+ return [Entry(r["code"], level, r["title_en"], r["title_fr"],
149
+ r["definition_en"]) for r in rows]
150
+
151
+ # -- recherche ------------------------------------------------------
152
+
153
+ def search(
154
+ self,
155
+ query: str,
156
+ lang: Optional[str] = None,
157
+ level: Optional[str] = None,
158
+ limit: int = 20,
159
+ ) -> list[Entry]:
160
+ """Recherche plein texte (FTS5, insensible aux accents), classée par
161
+ pertinence. ``lang``/``level`` restreignent la recherche ; le dernier
162
+ mot est traité en préfixe pour permettre la saisie incrémentale."""
163
+ tokens = [t for t in query.split() if t.strip('"*')]
164
+ if not tokens:
165
+ return []
166
+ fts_query = " ".join(f'"{t}"' for t in tokens[:-1]) + f' "{tokens[-1]}" *'
167
+ conditions, params = ["translations_fts MATCH ?"], [fts_query.strip()]
168
+ if lang:
169
+ conditions.append("lang = ?")
170
+ params.append(lang)
171
+ if level:
172
+ conditions.append("level = ?")
173
+ params.append(level)
174
+ rows = self._con.execute(
175
+ f"""
176
+ SELECT code, level FROM translations_fts
177
+ WHERE {' AND '.join(conditions)}
178
+ ORDER BY bm25(translations_fts) LIMIT ?
179
+ """,
180
+ (*params, limit),
181
+ ).fetchall()
182
+ results, seen = [], set()
183
+ for r in rows:
184
+ key = (r["code"], r["level"])
185
+ if key in seen:
186
+ continue
187
+ seen.add(key)
188
+ entry = self.get(r["code"], r["level"])
189
+ if entry:
190
+ results.append(entry)
191
+ return results
192
+
193
+ def close(self) -> None:
194
+ self._con.close()
195
+
196
+ def __enter__(self) -> "UNSPSC":
197
+ return self
198
+
199
+ def __exit__(self, *exc) -> None:
200
+ self.close()
@@ -0,0 +1,77 @@
1
+ """CLI : unspsc-fr get|search|children|ancestors."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from . import UNSPSC, Entry, __version__
9
+
10
+
11
+ def fmt(entry: Entry, as_json: bool) -> str:
12
+ if as_json:
13
+ return json.dumps(
14
+ {
15
+ "code": entry.code,
16
+ "level": entry.level,
17
+ "title_en": entry.title_en,
18
+ "title_fr": entry.title_fr,
19
+ },
20
+ ensure_ascii=False,
21
+ )
22
+ mark = "" if entry.is_translated else " [EN]"
23
+ return f"{entry.code} {entry.level:<9} {entry.title}{mark}"
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ parser = argparse.ArgumentParser(
28
+ prog="unspsc-fr",
29
+ description="Nomenclature UNSPSC bilingue EN/FR en ligne de commande",
30
+ )
31
+ parser.add_argument("--version", action="version", version=__version__)
32
+ parser.add_argument("--json", action="store_true", help="sortie JSON (une ligne par entrée)")
33
+ parser.add_argument("--db", help="chemin vers la base SQLite (sinon cache/téléchargement)")
34
+ sub = parser.add_subparsers(dest="command", required=True)
35
+
36
+ p_get = sub.add_parser("get", help="détail d'un code")
37
+ p_get.add_argument("code", type=int)
38
+
39
+ p_search = sub.add_parser("search", help="recherche plein texte")
40
+ p_search.add_argument("query")
41
+ p_search.add_argument("--lang", choices=["en", "fr"], help="restreindre à une langue")
42
+ p_search.add_argument("--level", choices=["segment", "family", "class", "commodity"])
43
+ p_search.add_argument("--limit", type=int, default=20)
44
+
45
+ p_children = sub.add_parser("children", help="enfants directs (sans code : les segments)")
46
+ p_children.add_argument("code", type=int, nargs="?")
47
+
48
+ p_anc = sub.add_parser("ancestors", help="chaîne segment > famille > classe d'un code")
49
+ p_anc.add_argument("code", type=int)
50
+
51
+ args = parser.parse_args(argv)
52
+ u = UNSPSC(db_path=args.db)
53
+
54
+ if args.command == "get":
55
+ entry = u.get(args.code)
56
+ if entry is None:
57
+ print(f"Code inconnu : {args.code}", file=sys.stderr)
58
+ return 1
59
+ print(fmt(entry, args.json))
60
+ if not args.json and entry.definition_en:
61
+ print(f" {entry.definition_en}")
62
+ return 0
63
+
64
+ if args.command == "search":
65
+ entries = u.search(args.query, lang=args.lang, level=args.level, limit=args.limit)
66
+ elif args.command == "children":
67
+ entries = u.children(args.code)
68
+ else:
69
+ entries = u.ancestors(args.code)
70
+
71
+ for entry in entries:
72
+ print(fmt(entry, args.json))
73
+ return 0 if entries else 1
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,96 @@
1
+ """Localisation, téléchargement et préparation de la base SQLite.
2
+
3
+ La base (~88 Mo) n'est pas embarquée dans le wheel : elle est téléchargée depuis
4
+ la release GitHub au premier usage puis mise en cache. Ordre de résolution :
5
+
6
+ 1. chemin passé explicitement à ``UNSPSC(db_path=...)``
7
+ 2. variable d'environnement ``UNSPSC_FR_DB``
8
+ 3. cache local (``$XDG_CACHE_HOME/unspsc-fr`` ou ``~/.cache/unspsc-fr``),
9
+ téléchargé si absent
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sqlite3
15
+ import sys
16
+ import urllib.request
17
+ from pathlib import Path
18
+
19
+ DATA_VERSION = "v26.0801"
20
+ DB_FILENAME = "unspsc-bilingue.sqlite"
21
+ DB_URL = (
22
+ "https://github.com/codebysaadbouh/unspsc-fr/releases/download/"
23
+ f"{DATA_VERSION}/{DB_FILENAME}"
24
+ )
25
+
26
+
27
+ def cache_dir() -> Path:
28
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
29
+ return Path(base) / "unspsc-fr" / DATA_VERSION
30
+
31
+
32
+ def resolve_db_path(db_path: str | os.PathLike | None = None) -> Path:
33
+ if db_path is not None:
34
+ path = Path(db_path)
35
+ if not path.exists():
36
+ raise FileNotFoundError(f"Base SQLite introuvable : {path}")
37
+ return path
38
+ env = os.environ.get("UNSPSC_FR_DB")
39
+ if env:
40
+ path = Path(env)
41
+ if not path.exists():
42
+ raise FileNotFoundError(f"UNSPSC_FR_DB pointe vers un fichier absent : {path}")
43
+ return path
44
+ cached = cache_dir() / DB_FILENAME
45
+ if not cached.exists():
46
+ download(cached)
47
+ return cached
48
+
49
+
50
+ def download(dest: Path) -> None:
51
+ dest.parent.mkdir(parents=True, exist_ok=True)
52
+ tmp = dest.with_suffix(".tmp")
53
+ print(
54
+ f"Téléchargement de la base UNSPSC {DATA_VERSION} (~88 Mo) vers {dest} …",
55
+ file=sys.stderr,
56
+ )
57
+ with urllib.request.urlopen(DB_URL) as resp, tmp.open("wb") as out:
58
+ while chunk := resp.read(1 << 20):
59
+ out.write(chunk)
60
+ tmp.rename(dest)
61
+
62
+
63
+ def connect(db_path: str | os.PathLike | None = None) -> sqlite3.Connection:
64
+ """Ouvre la base et s'assure que l'index plein texte existe."""
65
+ path = resolve_db_path(db_path)
66
+ con = sqlite3.connect(path)
67
+ con.row_factory = sqlite3.Row
68
+ ensure_fts(con)
69
+ return con
70
+
71
+
72
+ def ensure_fts(con: sqlite3.Connection) -> None:
73
+ """Construit l'index FTS5 des libellés à la première ouverture.
74
+
75
+ ``remove_diacritics 2`` rend la recherche insensible aux accents
76
+ (« electricite » trouve « électricité »).
77
+ """
78
+ exists = con.execute(
79
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'translations_fts'"
80
+ ).fetchone()
81
+ if exists:
82
+ return
83
+ con.executescript(
84
+ """
85
+ CREATE VIRTUAL TABLE translations_fts USING fts5(
86
+ title,
87
+ code UNINDEXED,
88
+ level UNINDEXED,
89
+ lang UNINDEXED,
90
+ tokenize = "unicode61 remove_diacritics 2"
91
+ );
92
+ INSERT INTO translations_fts (title, code, level, lang)
93
+ SELECT title, code, level, lang FROM translations;
94
+ """
95
+ )
96
+ con.commit()
@@ -0,0 +1,7 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ DIST_DB = Path(__file__).resolve().parents[2] / "dist" / "unspsc-bilingue.sqlite"
5
+
6
+ if "UNSPSC_FR_DB" not in os.environ and DIST_DB.exists():
7
+ os.environ["UNSPSC_FR_DB"] = str(DIST_DB)
@@ -0,0 +1,79 @@
1
+ """Tests contre la base réelle : UNSPSC_FR_DB doit pointer vers le SQLite
2
+ (par défaut ../dist/unspsc-bilingue.sqlite via conftest)."""
3
+ import pytest
4
+
5
+ from unspsc_fr import UNSPSC, level_of, parent_code
6
+
7
+
8
+ @pytest.fixture(scope="module")
9
+ def u():
10
+ with UNSPSC() as u:
11
+ yield u
12
+
13
+
14
+ def test_level_of():
15
+ assert level_of(10000000) == "segment"
16
+ assert level_of(10100000) == "family"
17
+ assert level_of(10101500) == "class"
18
+ assert level_of(10101501) == "commodity"
19
+
20
+
21
+ def test_parent_code():
22
+ assert parent_code(10101501, "commodity") == 10101500
23
+ assert parent_code(10101500, "class") == 10100000
24
+ assert parent_code(10100000, "family") == 10000000
25
+ assert parent_code(10000000, "segment") is None
26
+
27
+
28
+ def test_get_translated(u):
29
+ entry = u.get(10101501)
30
+ assert entry.title == "Chats"
31
+ assert entry.title_en == "Cats"
32
+ assert entry.is_translated
33
+
34
+
35
+ def test_get_fallback_en(u):
36
+ # segment 57000000 : seul segment sans traduction française
37
+ entry = u.get(57000000)
38
+ assert entry.title_fr is None
39
+ assert entry.title == entry.title_en
40
+
41
+
42
+ def test_get_unknown(u):
43
+ assert u.get(99999999) is None
44
+
45
+
46
+ def test_ambiguous_code_both_levels(u):
47
+ # 26132000 existe comme classe ET comme produit dans le codeset UNDP
48
+ assert u.get(26132000, level="class").level == "class"
49
+ assert u.get(26132000, level="commodity").level == "commodity"
50
+ assert u.get(26132000).level == "class" # niveau canonique d'abord
51
+
52
+
53
+ def test_children_and_segments(u):
54
+ assert len(u.children()) == 58
55
+ kittens = u.children(10101500)
56
+ assert any(e.code == 10101501 for e in kittens)
57
+ assert u.children(10101501) == []
58
+
59
+
60
+ def test_ancestors(u):
61
+ chain = u.ancestors(10101501)
62
+ assert [e.level for e in chain] == ["segment", "family", "class"]
63
+ assert chain[-1].code == 10101500
64
+
65
+
66
+ def test_search_accent_insensitive(u):
67
+ hits = u.search("electricite", lang="fr")
68
+ assert hits, "la recherche sans accents doit trouver « électricité »"
69
+ assert any("électri" in (e.title_fr or "").lower() for e in hits)
70
+
71
+
72
+ def test_search_prefix(u):
73
+ hits = u.search("cha", lang="fr", limit=50)
74
+ assert hits # saisie incrémentale : préfixe sur le dernier mot
75
+
76
+
77
+ def test_search_level_filter(u):
78
+ hits = u.search("cats", lang="en", level="commodity")
79
+ assert all(e.level == "commodity" for e in hits)