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.
- unspsc_fr-0.1.0/.gitignore +6 -0
- unspsc_fr-0.1.0/PKG-INFO +69 -0
- unspsc_fr-0.1.0/README.md +51 -0
- unspsc_fr-0.1.0/pyproject.toml +31 -0
- unspsc_fr-0.1.0/src/unspsc_fr/__init__.py +200 -0
- unspsc_fr-0.1.0/src/unspsc_fr/cli.py +77 -0
- unspsc_fr-0.1.0/src/unspsc_fr/db.py +96 -0
- unspsc_fr-0.1.0/tests/conftest.py +7 -0
- unspsc_fr-0.1.0/tests/test_unspsc.py +79 -0
unspsc_fr-0.1.0/PKG-INFO
ADDED
|
@@ -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,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)
|