polish-inflection 0.1.0__py3-none-any.whl
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.
- polish_inflection/__init__.py +58 -0
- polish_inflection/build.py +321 -0
- polish_inflection/const.py +37 -0
- polish_inflection/core.py +159 -0
- polish_inflection/data/.gitkeep +0 -0
- polish_inflection/data/BUILD_INFO.json +9 -0
- polish_inflection/data/odmien.marisa +0 -0
- polish_inflection/data/podaj.marisa +0 -0
- polish_inflection/errors.py +21 -0
- polish_inflection-0.1.0.dist-info/METADATA +291 -0
- polish_inflection-0.1.0.dist-info/RECORD +15 -0
- polish_inflection-0.1.0.dist-info/WHEEL +4 -0
- polish_inflection-0.1.0.dist-info/entry_points.txt +2 -0
- polish_inflection-0.1.0.dist-info/licenses/LICENSE +24 -0
- polish_inflection-0.1.0.dist-info/licenses/NOTICE.md +68 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""polish-inflection — odmiana polskich rzeczowników przez przypadki (dane SGJP).
|
|
2
|
+
|
|
3
|
+
Lekka, czysto-Pythonowa biblioteka: kierunek generacji (``odmien``) i analizy
|
|
4
|
+
zwrotnej (``podaj``), oparta o kompaktowe indeksy DAWG zbudowane z danych SGJP.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .const import (
|
|
8
|
+
BIERNIK,
|
|
9
|
+
CELOWNIK,
|
|
10
|
+
DOPEŁNIACZ,
|
|
11
|
+
LICZBY,
|
|
12
|
+
MIANOWNIK,
|
|
13
|
+
MIEJSCOWNIK,
|
|
14
|
+
MNOGA,
|
|
15
|
+
NARZĘDNIK,
|
|
16
|
+
POJEDYNCZA,
|
|
17
|
+
PRZYPADKI,
|
|
18
|
+
TEN_SAM_WYRAZ,
|
|
19
|
+
WOŁACZ,
|
|
20
|
+
)
|
|
21
|
+
from .core import (
|
|
22
|
+
odmien,
|
|
23
|
+
odmien_lub_none,
|
|
24
|
+
odmien_lub_wyraz,
|
|
25
|
+
odmien_warianty,
|
|
26
|
+
podaj,
|
|
27
|
+
)
|
|
28
|
+
from .errors import Analiza, BrakOdmiany
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# funkcje
|
|
34
|
+
"odmien",
|
|
35
|
+
"odmien_lub_none",
|
|
36
|
+
"odmien_lub_wyraz",
|
|
37
|
+
"odmien_warianty",
|
|
38
|
+
"podaj",
|
|
39
|
+
# typy / wyjątki
|
|
40
|
+
"Analiza",
|
|
41
|
+
"BrakOdmiany",
|
|
42
|
+
# stałe przypadków
|
|
43
|
+
"MIANOWNIK",
|
|
44
|
+
"DOPEŁNIACZ",
|
|
45
|
+
"CELOWNIK",
|
|
46
|
+
"BIERNIK",
|
|
47
|
+
"NARZĘDNIK",
|
|
48
|
+
"MIEJSCOWNIK",
|
|
49
|
+
"WOŁACZ",
|
|
50
|
+
"PRZYPADKI",
|
|
51
|
+
# liczby
|
|
52
|
+
"POJEDYNCZA",
|
|
53
|
+
"MNOGA",
|
|
54
|
+
"LICZBY",
|
|
55
|
+
# sentinel
|
|
56
|
+
"TEN_SAM_WYRAZ",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Pipeline BUILD (offline): SGJP ``.tab`` -> dwa indeksy ``BytesDAWG``.
|
|
2
|
+
|
|
3
|
+
Uruchamiany u autora / w CI, NIGDY przy ``pip install``. Buduje kompaktowy,
|
|
4
|
+
w pełni wyliczony indeks — runtime niczego nie generuje, tylko wyszukuje.
|
|
5
|
+
|
|
6
|
+
Zależności buildu (extra ``build``): ``dawg2`` (import ``dawg``) do zapisu,
|
|
7
|
+
``requests`` do ``refresh_sgjp``. Runtime tego nie potrzebuje.
|
|
8
|
+
|
|
9
|
+
Schemat wyjścia — patrz CONTRACT §D / :mod:`polish_inflection.core`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import gzip
|
|
16
|
+
import hashlib
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Iterable, Iterator
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import NamedTuple
|
|
24
|
+
|
|
25
|
+
from .const import LICZBY_SET, PRZYPADKI_SET
|
|
26
|
+
|
|
27
|
+
# ── Model rekordu po ekspansji ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Rekord(NamedTuple):
|
|
31
|
+
forma: str
|
|
32
|
+
lemat: str # goły lemat (bez sufiksu fleksemu)
|
|
33
|
+
liczba: str # "sg"/"pl"
|
|
34
|
+
przypadek: str # "nom".."voc"
|
|
35
|
+
rodzaj: str # "m1","f","n",...
|
|
36
|
+
depr: bool # True dla form deprecjatywnych (tag depr:)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Parsowanie + EKSPANSJA tagu SGJP ───────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def goly_lemat(lemat: str) -> str:
|
|
43
|
+
"""Utnij sufiks fleksemu/homonimu: ``profesor:Sm1`` -> ``profesor``."""
|
|
44
|
+
return lemat.split(":", 1)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parsuj_linie(linie: Iterable[str]) -> Iterator[Rekord]:
|
|
48
|
+
"""Parsuj i ROZWIŃ linie SGJP ``.tab`` na pojedyncze rekordy.
|
|
49
|
+
|
|
50
|
+
Tag NIE jest 1:1: pola ``liczby`` i ``przypadki`` to kropkowo sklejone
|
|
51
|
+
podzbiory (``subst:sg:nom.acc:m3`` -> dwa rekordy). Pomija nagłówki (``#``),
|
|
52
|
+
tagi inne niż ``subst``/``depr`` oraz wpisy niekompletne/nietypowe.
|
|
53
|
+
"""
|
|
54
|
+
for linia in linie:
|
|
55
|
+
linia = linia.rstrip("\n")
|
|
56
|
+
if not linia or linia.startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
pola = linia.split("\t")
|
|
59
|
+
if len(pola) < 3:
|
|
60
|
+
continue
|
|
61
|
+
forma, lemat, tag = pola[0], pola[1], pola[2]
|
|
62
|
+
if not forma or not tag:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
czesci = tag.split(":")
|
|
66
|
+
pos = czesci[0]
|
|
67
|
+
if pos not in ("subst", "depr"):
|
|
68
|
+
continue
|
|
69
|
+
if len(czesci) < 4:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
depr = pos == "depr"
|
|
73
|
+
bare = goly_lemat(lemat)
|
|
74
|
+
liczby = czesci[1].split(".")
|
|
75
|
+
przypadki = czesci[2].split(".")
|
|
76
|
+
rodzaj = czesci[3].split(".")[0] # rodzaj bywa kropkowany — bierz główny
|
|
77
|
+
|
|
78
|
+
for liczba in liczby:
|
|
79
|
+
if liczba not in LICZBY_SET:
|
|
80
|
+
continue
|
|
81
|
+
for przypadek in przypadki:
|
|
82
|
+
if przypadek not in PRZYPADKI_SET:
|
|
83
|
+
continue
|
|
84
|
+
yield Rekord(forma, bare, liczba, przypadek, rodzaj, depr)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Budowa indeksów (marisa-trie BytesTrie) ────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pary_dawg(rekordy: Iterable[Rekord]):
|
|
91
|
+
"""Zwróć (pary_odmien, pary_podaj) jako listy (klucz:str, wartosc:bytes).
|
|
92
|
+
|
|
93
|
+
odmien: tylko subst (formy główne). podaj: subst + depr.
|
|
94
|
+
Deduplikacja identycznych par.
|
|
95
|
+
"""
|
|
96
|
+
odmien_set: set[tuple[str, bytes]] = set()
|
|
97
|
+
podaj_set: set[tuple[str, bytes]] = set()
|
|
98
|
+
for r in rekordy:
|
|
99
|
+
wart_podaj = f"{r.lemat}\t{r.przypadek}\t{r.liczba}\t{r.rodzaj}".encode()
|
|
100
|
+
podaj_set.add((r.forma, wart_podaj))
|
|
101
|
+
if not r.depr:
|
|
102
|
+
klucz = f"{r.lemat}\t{r.przypadek}\t{r.liczba}"
|
|
103
|
+
odmien_set.add((klucz, r.forma.encode("utf-8")))
|
|
104
|
+
return sorted(odmien_set), sorted(podaj_set)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def zbuduj_dawgi(rekordy: Iterable[Rekord]):
|
|
108
|
+
"""Zbuduj i zwróć oba ``marisa_trie.BytesTrie`` (odmien, podaj)."""
|
|
109
|
+
import marisa_trie
|
|
110
|
+
|
|
111
|
+
pary_odmien, pary_podaj = _pary_dawg(rekordy)
|
|
112
|
+
return marisa_trie.BytesTrie(pary_odmien), marisa_trie.BytesTrie(pary_podaj)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Czytanie źródła ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _otworz_tab(sciezka: Path) -> Iterator[str]:
|
|
119
|
+
"""Otwórz ``.tab`` lub ``.tab.gz`` jako strumień linii (utf-8)."""
|
|
120
|
+
if sciezka.suffix == ".gz":
|
|
121
|
+
with gzip.open(sciezka, "rt", encoding="utf-8") as f:
|
|
122
|
+
yield from f
|
|
123
|
+
else:
|
|
124
|
+
with open(sciezka, encoding="utf-8") as f:
|
|
125
|
+
yield from f
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_DICT_ID_RE = re.compile(r"#!DICT-ID\s+(\S+)")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def wersja_sgjp(sciezka: Path) -> str | None:
|
|
132
|
+
"""Odczytaj wersję z nagłówka ``#!DICT-ID pl.sgjp.sgjp-YYYY.MM.DD``."""
|
|
133
|
+
for linia in _otworz_tab(sciezka):
|
|
134
|
+
m = _DICT_ID_RE.search(linia)
|
|
135
|
+
if m:
|
|
136
|
+
return m.group(1)
|
|
137
|
+
if not linia.startswith("#"):
|
|
138
|
+
break
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Pełny bieg buildu ──────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def zbuduj_z_tab(sciezka_tab: Path, out_dir: Path, *, data_build: str | None = None) -> dict:
|
|
146
|
+
"""Zbuduj ``odmien.marisa``/``podaj.marisa`` + ``BUILD_INFO.json`` w ``out_dir``.
|
|
147
|
+
|
|
148
|
+
Zwraca słownik BUILD_INFO. ``data_build`` przekazuje wywołujący (brak zegara
|
|
149
|
+
w bibliotece); ``None`` -> pole pominięte.
|
|
150
|
+
"""
|
|
151
|
+
sciezka_tab = Path(sciezka_tab)
|
|
152
|
+
out_dir = Path(out_dir)
|
|
153
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
rekordy = list(parsuj_linie(_otworz_tab(sciezka_tab)))
|
|
156
|
+
odmien_trie, podaj_trie = zbuduj_dawgi(rekordy)
|
|
157
|
+
|
|
158
|
+
odmien_path = out_dir / "odmien.marisa"
|
|
159
|
+
podaj_path = out_dir / "podaj.marisa"
|
|
160
|
+
odmien_trie.save(str(odmien_path))
|
|
161
|
+
podaj_trie.save(str(podaj_path))
|
|
162
|
+
|
|
163
|
+
lematy = {r.lemat for r in rekordy}
|
|
164
|
+
formy = {(r.forma, r.lemat, r.liczba, r.przypadek) for r in rekordy}
|
|
165
|
+
info = {
|
|
166
|
+
"wersja_sgjp": wersja_sgjp(sciezka_tab),
|
|
167
|
+
"data_build": data_build,
|
|
168
|
+
"liczba_lematow": len(lematy),
|
|
169
|
+
"liczba_form": len(formy),
|
|
170
|
+
"liczba_rekordow": len(rekordy),
|
|
171
|
+
"rozmiar_odmien_marisa": odmien_path.stat().st_size,
|
|
172
|
+
"rozmiar_podaj_marisa": podaj_path.stat().st_size,
|
|
173
|
+
}
|
|
174
|
+
(out_dir / "BUILD_INFO.json").write_text(
|
|
175
|
+
json.dumps(info, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
176
|
+
)
|
|
177
|
+
return info
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── refresh-sgjp (jedyne miejsce sięgające do sieci) ───────────────────────
|
|
181
|
+
|
|
182
|
+
_BAZA_URL = "https://download.sgjp.pl/morfeusz"
|
|
183
|
+
_KATALOG_RE = re.compile(r'href="(\d{8})/"')
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _najnowsza_wersja(sesja) -> str:
|
|
187
|
+
"""Znajdź najnowszy katalog dat (``YYYYMMDD``) w indeksie SGJP."""
|
|
188
|
+
r = sesja.get(f"{_BAZA_URL}/", timeout=60)
|
|
189
|
+
r.raise_for_status()
|
|
190
|
+
daty = sorted(set(_KATALOG_RE.findall(r.text)))
|
|
191
|
+
if not daty:
|
|
192
|
+
raise RuntimeError("Nie znaleziono katalogów wersji SGJP.")
|
|
193
|
+
return daty[-1]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _wytnij_licencje(naglowek: str) -> str | None:
|
|
197
|
+
"""Wytnij blok ``#<COPYRIGHT> ... #</COPYRIGHT>`` (verbatim) z nagłówka."""
|
|
198
|
+
linie = []
|
|
199
|
+
zapisuj = False
|
|
200
|
+
for linia in naglowek.splitlines():
|
|
201
|
+
if linia.startswith("#<COPYRIGHT>"):
|
|
202
|
+
zapisuj = True
|
|
203
|
+
continue
|
|
204
|
+
if linia.startswith("#</COPYRIGHT>"):
|
|
205
|
+
break
|
|
206
|
+
if zapisuj:
|
|
207
|
+
linie.append(linia)
|
|
208
|
+
return "\n".join(linie).strip() + "\n" if linie else None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def refresh_sgjp(dane_dir: Path, *, wersja: str | None = None, data_pobrania: str | None = None):
|
|
212
|
+
"""Pobierz najnowszy (lub wskazany) SGJP ``.tab.gz``, zwendoruj, zaktualizuj pin.
|
|
213
|
+
|
|
214
|
+
Zapisuje ``sgjp-<wersja>.tab.gz``, ``PIN.json`` i ``LICENSE.sgjp``.
|
|
215
|
+
"""
|
|
216
|
+
import requests
|
|
217
|
+
|
|
218
|
+
dane_dir = Path(dane_dir)
|
|
219
|
+
dane_dir.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
sesja = requests.Session()
|
|
221
|
+
|
|
222
|
+
if wersja is None:
|
|
223
|
+
wersja = _najnowsza_wersja(sesja)
|
|
224
|
+
url = f"{_BAZA_URL}/{wersja}/sgjp-{wersja}.tab.gz"
|
|
225
|
+
|
|
226
|
+
print(f"Pobieram {url} ...", file=sys.stderr)
|
|
227
|
+
r = sesja.get(url, timeout=600)
|
|
228
|
+
r.raise_for_status()
|
|
229
|
+
dane = r.content
|
|
230
|
+
sha256 = hashlib.sha256(dane).hexdigest()
|
|
231
|
+
|
|
232
|
+
plik = dane_dir / f"sgjp-{wersja}.tab.gz"
|
|
233
|
+
plik.write_bytes(dane)
|
|
234
|
+
|
|
235
|
+
# Nagłówek do wersji i licencji. UWAGA: treść bloku #<COPYRIGHT>..#</COPYRIGHT>
|
|
236
|
+
# to zwykłe linie BEZ prefiksu '#', więc nie można przerywać na pierwszej
|
|
237
|
+
# linii bez '#'; czytamy dalej dopóki jesteśmy wewnątrz bloku licencji.
|
|
238
|
+
with gzip.open(io.BytesIO(dane), "rt", encoding="utf-8") as f:
|
|
239
|
+
naglowek_linie = []
|
|
240
|
+
wewnatrz_licencji = False
|
|
241
|
+
for linia in f:
|
|
242
|
+
s = linia.rstrip("\n")
|
|
243
|
+
if not wewnatrz_licencji and not s.startswith("#"):
|
|
244
|
+
break
|
|
245
|
+
naglowek_linie.append(linia)
|
|
246
|
+
if s.startswith("#<COPYRIGHT>"):
|
|
247
|
+
wewnatrz_licencji = True
|
|
248
|
+
elif s.startswith("#</COPYRIGHT>"):
|
|
249
|
+
wewnatrz_licencji = False
|
|
250
|
+
naglowek = "".join(naglowek_linie)
|
|
251
|
+
m = _DICT_ID_RE.search(naglowek)
|
|
252
|
+
dict_id = m.group(1) if m else None
|
|
253
|
+
|
|
254
|
+
licencja = _wytnij_licencje(naglowek)
|
|
255
|
+
if licencja:
|
|
256
|
+
(dane_dir / "LICENSE.sgjp").write_text(licencja, encoding="utf-8")
|
|
257
|
+
|
|
258
|
+
pin = {
|
|
259
|
+
"wersja": wersja,
|
|
260
|
+
"dict_id": dict_id,
|
|
261
|
+
"data_pobrania": data_pobrania,
|
|
262
|
+
"sha256": sha256,
|
|
263
|
+
"url_zrodlowy": url,
|
|
264
|
+
"plik": plik.name,
|
|
265
|
+
}
|
|
266
|
+
(dane_dir / "PIN.json").write_text(
|
|
267
|
+
json.dumps(pin, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
268
|
+
)
|
|
269
|
+
print(f"Zapisano {plik.name} (sha256={sha256[:12]}…), zaktualizowano PIN.json", file=sys.stderr)
|
|
270
|
+
return pin
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── CLI ────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _domyslny_pin_tab(dane_dir: Path) -> Path:
|
|
277
|
+
"""Znajdź vendorowany ``.tab.gz`` wg ``PIN.json`` (lub jedyny w katalogu)."""
|
|
278
|
+
pin_path = dane_dir / "PIN.json"
|
|
279
|
+
if pin_path.exists():
|
|
280
|
+
pin = json.loads(pin_path.read_text(encoding="utf-8"))
|
|
281
|
+
nazwa = pin.get("plik")
|
|
282
|
+
if nazwa and (dane_dir / nazwa).exists():
|
|
283
|
+
return dane_dir / nazwa
|
|
284
|
+
kandydaci = sorted(dane_dir.glob("*.tab.gz"))
|
|
285
|
+
if not kandydaci:
|
|
286
|
+
raise SystemExit(f"Brak vendorowanego SGJP w {dane_dir}. Uruchom `refresh-sgjp`.")
|
|
287
|
+
return kandydaci[-1]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main(argv: list[str] | None = None) -> int:
|
|
291
|
+
repo = Path(__file__).resolve().parents[2]
|
|
292
|
+
parser = argparse.ArgumentParser(prog="polish-inflection-build")
|
|
293
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
294
|
+
|
|
295
|
+
p_build = sub.add_parser("build", help="Zbuduj indeksy z vendorowanego pinu (bez sieci).")
|
|
296
|
+
p_build.add_argument("--tab", type=Path, default=None, help="Ścieżka do .tab/.tab.gz")
|
|
297
|
+
p_build.add_argument("--out", type=Path, default=repo / "src" / "polish_inflection" / "data")
|
|
298
|
+
p_build.add_argument("--data-build", default=None, help="Data buildu (do BUILD_INFO).")
|
|
299
|
+
|
|
300
|
+
p_refresh = sub.add_parser("refresh-sgjp", help="Pobierz najnowszy SGJP i zaktualizuj pin.")
|
|
301
|
+
p_refresh.add_argument("--wersja", default=None, help="Konkretna wersja YYYYMMDD.")
|
|
302
|
+
p_refresh.add_argument("--data-pobrania", default=None)
|
|
303
|
+
p_refresh.add_argument("--dane-dir", type=Path, default=repo / "data" / "sgjp")
|
|
304
|
+
|
|
305
|
+
args = parser.parse_args(argv)
|
|
306
|
+
|
|
307
|
+
if args.cmd == "build":
|
|
308
|
+
tab = args.tab or _domyslny_pin_tab(repo / "data" / "sgjp")
|
|
309
|
+
info = zbuduj_z_tab(tab, args.out, data_build=args.data_build)
|
|
310
|
+
print(json.dumps(info, ensure_ascii=False, indent=2))
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
if args.cmd == "refresh-sgjp":
|
|
314
|
+
refresh_sgjp(args.dane_dir, wersja=args.wersja, data_pobrania=args.data_pobrania)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
return 1
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Nazwane stałe przypadków i liczby (mapowane 1:1 na tagi SGJP) oraz sentinel
|
|
2
|
+
``TEN_SAM_WYRAZ`` dla trybu passthrough w :func:`polish_inflection.core.odmien`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Przypadki — wartości identyczne z tagami SGJP.
|
|
6
|
+
MIANOWNIK = "nom" # kto? co?
|
|
7
|
+
DOPEŁNIACZ = "gen" # kogo? czego?
|
|
8
|
+
CELOWNIK = "dat" # komu? czemu?
|
|
9
|
+
BIERNIK = "acc" # kogo? co?
|
|
10
|
+
NARZĘDNIK = "inst" # (z) kim? (z) czym?
|
|
11
|
+
MIEJSCOWNIK = "loc" # o kim? o czym?
|
|
12
|
+
WOŁACZ = "voc" # o!
|
|
13
|
+
|
|
14
|
+
# Liczba.
|
|
15
|
+
POJEDYNCZA = "sg"
|
|
16
|
+
MNOGA = "pl"
|
|
17
|
+
|
|
18
|
+
PRZYPADKI = (MIANOWNIK, DOPEŁNIACZ, CELOWNIK, BIERNIK, NARZĘDNIK, MIEJSCOWNIK, WOŁACZ)
|
|
19
|
+
LICZBY = (POJEDYNCZA, MNOGA)
|
|
20
|
+
|
|
21
|
+
# Zbiory do szybkiej walidacji w buildzie/runtime.
|
|
22
|
+
PRZYPADKI_SET = frozenset(PRZYPADKI)
|
|
23
|
+
LICZBY_SET = frozenset(LICZBY)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _TenSamWyraz:
|
|
27
|
+
"""Sentinel: gdy przekazany jako ``default`` do ``odmien`` i brak formy —
|
|
28
|
+
zwróć wejściowy wyraz zamiast rzucać wyjątek."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ()
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return "TEN_SAM_WYRAZ"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Wartownik dla ``odmien(..., default=TEN_SAM_WYRAZ)`` — passthrough wejścia.
|
|
37
|
+
TEN_SAM_WYRAZ = _TenSamWyraz()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Runtime API: odmiana (``odmien``) i analiza zwrotna (``podaj``).
|
|
2
|
+
|
|
3
|
+
Czyta gotowe indeksy ``odmien.marisa`` / ``podaj.marisa`` (marisa-trie
|
|
4
|
+
``BytesTrie``) leniwie i przez ``mmap`` (stała pamięć — import nie ładuje
|
|
5
|
+
słownika do RAM). Schemat kluczy/wartości opisuje CONTRACT §D:
|
|
6
|
+
|
|
7
|
+
odmien.marisa: klucz "lemat\\tprzypadek\\tliczba" -> wartość: forma (utf-8)
|
|
8
|
+
podaj.marisa: klucz "forma" -> wartość: "lemat\\tprzypadek\\tliczba\\trodzaj"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import marisa_trie
|
|
17
|
+
|
|
18
|
+
from .const import POJEDYNCZA, TEN_SAM_WYRAZ
|
|
19
|
+
from .errors import Analiza, BrakOdmiany
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"odmien",
|
|
23
|
+
"odmien_lub_none",
|
|
24
|
+
"odmien_lub_wyraz",
|
|
25
|
+
"odmien_warianty",
|
|
26
|
+
"podaj",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Wewnętrzny wartownik: "default nie podany -> rzuć BrakOdmiany".
|
|
30
|
+
_BRAK = object()
|
|
31
|
+
|
|
32
|
+
# Leniwie ładowane, mmapowane indeksy. Cache modułowy.
|
|
33
|
+
_odmien_dawg = None
|
|
34
|
+
_podaj_dawg = None
|
|
35
|
+
|
|
36
|
+
# Katalog z danymi — domyślnie pakietowy ``polish_inflection/data``.
|
|
37
|
+
# Testy podmieniają go przez ``_ustaw_katalog_danych``.
|
|
38
|
+
_katalog_danych: Path | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _domyslny_katalog_danych() -> Path:
|
|
42
|
+
return Path(str(files("polish_inflection") / "data"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ustaw_katalog_danych(sciezka) -> None:
|
|
46
|
+
"""Wskaż inny katalog z ``odmien.dawg``/``podaj.dawg`` i wyczyść cache.
|
|
47
|
+
|
|
48
|
+
Używane w testach (fixture DAWG budowany do tmp) oraz w integracji.
|
|
49
|
+
``None`` przywraca katalog pakietowy.
|
|
50
|
+
"""
|
|
51
|
+
global _katalog_danych, _odmien_dawg, _podaj_dawg
|
|
52
|
+
_katalog_danych = Path(sciezka) if sciezka is not None else None
|
|
53
|
+
_odmien_dawg = None
|
|
54
|
+
_podaj_dawg = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _wczytaj(nazwa: str):
|
|
58
|
+
katalog = _katalog_danych or _domyslny_katalog_danych()
|
|
59
|
+
sciezka = katalog / nazwa
|
|
60
|
+
if not sciezka.exists():
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"Brak zbudowanego indeksu {sciezka}. "
|
|
63
|
+
"Uruchom build: `polish-inflection-build build` (patrz docs/budowanie.md)."
|
|
64
|
+
)
|
|
65
|
+
trie = marisa_trie.BytesTrie()
|
|
66
|
+
trie.mmap(str(sciezka)) # memory-mapped — stała pamięć, bez wczytywania do RAM
|
|
67
|
+
return trie
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dawg_odmien():
|
|
71
|
+
global _odmien_dawg
|
|
72
|
+
if _odmien_dawg is None:
|
|
73
|
+
_odmien_dawg = _wczytaj("odmien.marisa")
|
|
74
|
+
return _odmien_dawg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dawg_podaj():
|
|
78
|
+
global _podaj_dawg
|
|
79
|
+
if _podaj_dawg is None:
|
|
80
|
+
_podaj_dawg = _wczytaj("podaj.marisa")
|
|
81
|
+
return _podaj_dawg
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _formy(wyraz: str, przypadek: str, liczba: str) -> list[str]:
|
|
85
|
+
"""Posortowana lista poprawnych form w slocie (może być pusta)."""
|
|
86
|
+
klucz = f"{wyraz}\t{przypadek}\t{liczba}"
|
|
87
|
+
wartosci = _dawg_odmien().get(klucz)
|
|
88
|
+
if not wartosci:
|
|
89
|
+
return []
|
|
90
|
+
return sorted(v.decode("utf-8") for v in wartosci)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def odmien(wyraz: str, przypadek: str, liczba: str = POJEDYNCZA, *, default=_BRAK):
|
|
94
|
+
"""Zwróć główną formę ``wyraz`` w danym przypadku i liczbie.
|
|
95
|
+
|
|
96
|
+
Zachowanie przy braku formy (słowo spoza słownika lub liczba nieistniejąca
|
|
97
|
+
dla lematu, np. ``sg`` dla plurale tantum):
|
|
98
|
+
|
|
99
|
+
- ``default`` niepodany -> ``raise BrakOdmiany``
|
|
100
|
+
- ``default is TEN_SAM_WYRAZ`` -> zwróć ``wyraz`` (passthrough)
|
|
101
|
+
- ``default is None`` -> zwróć ``None``
|
|
102
|
+
- ``default = <cokolwiek>`` -> zwróć ``default``
|
|
103
|
+
|
|
104
|
+
Główna forma = pierwsza po sortowaniu bajtowym wartości slotu (deterministyczna).
|
|
105
|
+
|
|
106
|
+
>>> odmien("wydział", "gen")
|
|
107
|
+
'wydziału'
|
|
108
|
+
>>> odmien("wydział", "gen", "pl")
|
|
109
|
+
'wydziałów'
|
|
110
|
+
"""
|
|
111
|
+
formy = _formy(wyraz, przypadek, liczba)
|
|
112
|
+
if formy:
|
|
113
|
+
return formy[0]
|
|
114
|
+
if default is _BRAK:
|
|
115
|
+
raise BrakOdmiany((wyraz, przypadek, liczba))
|
|
116
|
+
if default is TEN_SAM_WYRAZ:
|
|
117
|
+
return wyraz
|
|
118
|
+
return default
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def odmien_lub_none(wyraz: str, przypadek: str, liczba: str = POJEDYNCZA):
|
|
122
|
+
"""Jak ``odmien``, ale przy braku formy zwraca ``None`` (nie rzuca)."""
|
|
123
|
+
return odmien(wyraz, przypadek, liczba, default=None)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def odmien_lub_wyraz(wyraz: str, przypadek: str, liczba: str = POJEDYNCZA) -> str:
|
|
127
|
+
"""Jak ``odmien``, ale przy braku formy zwraca wejściowy ``wyraz`` (passthrough)."""
|
|
128
|
+
return odmien(wyraz, przypadek, liczba, default=TEN_SAM_WYRAZ)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def odmien_warianty(wyraz: str, przypadek: str, liczba: str = POJEDYNCZA) -> list[str]:
|
|
132
|
+
"""Wszystkie poprawne formy w slocie (oboczności), posortowane. ``[]`` gdy brak."""
|
|
133
|
+
return _formy(wyraz, przypadek, liczba)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def podaj(wyraz: str, liczba: str | None = None) -> list[Analiza]:
|
|
137
|
+
"""Kierunek zwrotny: forma -> lista analiz.
|
|
138
|
+
|
|
139
|
+
Zwraca LISTĘ, bo polszczyzna ma synkretyzm (jedna forma = wiele przypadków)
|
|
140
|
+
i homografię (jedna forma = wiele lematów). Uwzględnia też formy
|
|
141
|
+
deprecjatywne (``depr``). Opcjonalny ``liczba`` ("sg"/"pl") zawęża wynik.
|
|
142
|
+
Nieznana forma -> ``[]``.
|
|
143
|
+
|
|
144
|
+
>>> podaj("jednostki") # doctest: +SKIP
|
|
145
|
+
[Analiza('jednostka','gen','sg','f'), Analiza('jednostka','nom','pl','f'), ...]
|
|
146
|
+
"""
|
|
147
|
+
wartosci = _dawg_podaj().get(wyraz)
|
|
148
|
+
if not wartosci:
|
|
149
|
+
return []
|
|
150
|
+
analizy: set[Analiza] = set()
|
|
151
|
+
for w in wartosci:
|
|
152
|
+
pola = w.decode("utf-8").split("\t")
|
|
153
|
+
if len(pola) != 4:
|
|
154
|
+
continue
|
|
155
|
+
lemat, przypadek, lb, rodzaj = pola
|
|
156
|
+
if liczba is not None and lb != liczba:
|
|
157
|
+
continue
|
|
158
|
+
analizy.add(Analiza(lemat, przypadek, lb, rodzaj))
|
|
159
|
+
return sorted(analizy, key=lambda a: (a.liczba, a.przypadek, a.lemat, a.rodzaj))
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Typy wyników i wyjątki publicznego API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Analiza(NamedTuple):
|
|
9
|
+
"""Pojedyncza analiza morfologiczna formy (kierunek zwrotny ``podaj``)."""
|
|
10
|
+
|
|
11
|
+
lemat: str
|
|
12
|
+
przypadek: str # jedna ze stałych PRZYPADKI: "nom".."voc"
|
|
13
|
+
liczba: str # "sg" / "pl"
|
|
14
|
+
rodzaj: str # tag rodzaju SGJP: "m1","m2","m3","f","n","ncol",...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BrakOdmiany(KeyError):
|
|
18
|
+
"""Brak formy dla (wyraz, przypadek, liczba) i nie podano ``default``.
|
|
19
|
+
|
|
20
|
+
Dziedziczy po ``KeyError`` — bo to w istocie brak klucza w słowniku form.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polish-inflection
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lekka odmiana polskich rzeczowników przez przypadki (dane SGJP, indeks marisa-trie, mmap).
|
|
5
|
+
Project-URL: Homepage, https://github.com/mpasternak/polish-inflection
|
|
6
|
+
Project-URL: Repository, https://github.com/mpasternak/polish-inflection
|
|
7
|
+
Author-email: Michał Pasternak <m@iplweb.pl>
|
|
8
|
+
License: BSD-2-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: NOTICE.md
|
|
11
|
+
Keywords: declension,inflection,morphology,nlp,odmiana,polish,przypadki,sgjp
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
15
|
+
Classifier: Natural Language :: Polish
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: marisa-trie>=1.0
|
|
25
|
+
Provides-Extra: build
|
|
26
|
+
Requires-Dist: requests>=2.28; extra == 'build'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# polish-inflection
|
|
30
|
+
|
|
31
|
+
[](https://github.com/iplweb/polish-inflection/actions/workflows/ci.yml)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
Lekka, dane-only odmiana polskich **rzeczowników** przez przypadki.
|
|
35
|
+
Dane pochodzą ze [Słownika gramatycznego języka polskiego (SGJP)](https://morfeusz.sgjp.pl/),
|
|
36
|
+
wyszukiwanie oparte jest o kompaktowy indeks `marisa-trie` (mmap, ~1 µs/lookup).
|
|
37
|
+
Instalacja bez kompilatora (gotowe binarne wheele), zero Django, jedna
|
|
38
|
+
zależność runtime.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 🇵🇱 Wersja polska
|
|
43
|
+
|
|
44
|
+
### Cel
|
|
45
|
+
|
|
46
|
+
Chcesz z wyrazu `wydział` dostać `wydziału` (dopełniacz), `wydziałowi`
|
|
47
|
+
(celownik), `wydziałów` (dopełniacz liczby mnogiej) — programowo, bez trzymania
|
|
48
|
+
form ręcznie w tabeli. `polish-inflection` odmienia rzeczowniki przez
|
|
49
|
+
**7 przypadków × 2 liczby** w obie strony:
|
|
50
|
+
|
|
51
|
+
- generacja: `lemat + przypadek + liczba → forma` (`odmien`);
|
|
52
|
+
- analiza (kierunek zwrotny): `forma → [analizy]` (`podaj`).
|
|
53
|
+
|
|
54
|
+
### Dlaczego — luka w ekosystemie
|
|
55
|
+
|
|
56
|
+
Nie istniało lekkie, dane-only, przyjazne wdrożeniu rozwiązanie do
|
|
57
|
+
odmiany polskich rzeczowników przez przypadki:
|
|
58
|
+
|
|
59
|
+
- **gettext / formy mnogie** rozwiązują *inny* problem — wybór wariantu wg
|
|
60
|
+
**liczby** (`nplurals`), a nie odmianę przez **przypadki**. gettext nie zna
|
|
61
|
+
pojęcia przypadka i nie potrafi odmienić rzeczownika.
|
|
62
|
+
- **Morfeusz 2** to kanoniczny analizator i generator morfologii polskiej, ale
|
|
63
|
+
natywny silnik C++ + bindingi SWIG + kompilacja słownika rzędu dziesiątek MB.
|
|
64
|
+
Armata na muchę i ból wdrożeniowy w kontenerze. Tu nie ma silnika, SWIG-a ani
|
|
65
|
+
kompilacji słownika — tylko dane i `pip install`.
|
|
66
|
+
- **pymorphy2 / pymorphy3** obsługują tylko rosyjski i ukraiński — nie polski.
|
|
67
|
+
- **inflection / inflect / pyinflect** — tylko angielski.
|
|
68
|
+
|
|
69
|
+
Kluczowa idea: bierzemy **dane** SGJP (trójki `forma / lemat / tag`), a odrzucamy
|
|
70
|
+
**silnik**. Zbiór form jest zamknięty i w pełni wyliczony — nic nie generujemy
|
|
71
|
+
w locie, tylko indeksujemy i wyszukujemy.
|
|
72
|
+
|
|
73
|
+
### Instalacja
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install polish-inflection
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Jedyna zależność runtime to `marisa-trie` — kompilowane rozszerzenie C++, które
|
|
80
|
+
dostarcza gotowe binarne wheele (Linux/macOS/Windows, cp39–cp313), więc
|
|
81
|
+
`pip install` nie potrzebuje kompilatora ani narzędzi build. Pakiet wiezie gotowe
|
|
82
|
+
indeksy `.marisa` w wheelu — bez SGJP. Czytnik używa **mmap**: import nie ładuje
|
|
83
|
+
słownika do RAM, a pojedynczy lookup stronicuje tylko O(długość słowa) węzłów
|
|
84
|
+
(~1 µs, mierzony narzut RSS ~1,5 MB zamiast ~23 MB pełnego wczytania).
|
|
85
|
+
|
|
86
|
+
### Przykłady — `odmien`
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from polish_inflection import odmien, DOPEŁNIACZ, MNOGA
|
|
90
|
+
|
|
91
|
+
odmien("wydział", DOPEŁNIACZ) # -> "wydziału"
|
|
92
|
+
odmien("wydział", DOPEŁNIACZ, MNOGA) # -> "wydziałów"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Zachowanie przy słowie spoza słownika jest sterowane parametrem `default`:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from polish_inflection import (
|
|
99
|
+
odmien, odmien_lub_none, odmien_lub_wyraz, BrakOdmiany, DOPEŁNIACZ,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
odmien("wydział", DOPEŁNIACZ) # "wydziału"
|
|
103
|
+
odmien("qwerty", DOPEŁNIACZ) # raise BrakOdmiany
|
|
104
|
+
odmien_lub_none("qwerty", DOPEŁNIACZ) # None
|
|
105
|
+
odmien_lub_wyraz("qwerty", DOPEŁNIACZ) # "qwerty" (passthrough wejścia)
|
|
106
|
+
odmien("qwerty", DOPEŁNIACZ, default="—") # "—" (dowolny fallback)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Oboczności (kilka poprawnych form w jednym slocie) zwraca `odmien_warianty`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from polish_inflection import odmien_warianty, MIEJSCOWNIK
|
|
113
|
+
odmien_warianty("pokój", MIEJSCOWNIK) # -> lista wszystkich poprawnych form
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Przykłady — `podaj` (kierunek zwrotny)
|
|
117
|
+
|
|
118
|
+
Polszczyzna ma synkretyzm (jedna forma = wiele przypadków) i homografię (jedna
|
|
119
|
+
forma = wiele lematów), więc `podaj` zwraca **listę** analiz:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from polish_inflection import podaj
|
|
123
|
+
|
|
124
|
+
podaj("jednostki")
|
|
125
|
+
# [Analiza(lemat='jednostka', przypadek='gen', liczba='sg', rodzaj='f'),
|
|
126
|
+
# Analiza(lemat='jednostka', przypadek='nom', liczba='pl', rodzaj='f'),
|
|
127
|
+
# Analiza(lemat='jednostka', przypadek='acc', liczba='pl', rodzaj='f'),
|
|
128
|
+
# Analiza(lemat='jednostka', przypadek='voc', liczba='pl', rodzaj='f')]
|
|
129
|
+
|
|
130
|
+
podaj("jednostki", liczba="pl") # zawężenie do liczby mnogiej
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`Analiza` to lekki `NamedTuple`: `(lemat, przypadek, liczba, rodzaj)`.
|
|
134
|
+
|
|
135
|
+
### Stałe
|
|
136
|
+
|
|
137
|
+
Przypadki: `MIANOWNIK`, `DOPEŁNIACZ`, `CELOWNIK`, `BIERNIK`, `NARZĘDNIK`,
|
|
138
|
+
`MIEJSCOWNIK`, `WOŁACZ`. Liczby: `POJEDYNCZA`, `MNOGA` (domyślnie `POJEDYNCZA`).
|
|
139
|
+
Sentinel `TEN_SAM_WYRAZ` służy jako `default` zwracający wejściowy wyraz.
|
|
140
|
+
|
|
141
|
+
### Źródło danych i atrybucja
|
|
142
|
+
|
|
143
|
+
Dane fleksyjne pochodzą ze **Słownika gramatycznego języka polskiego (SGJP)**,
|
|
144
|
+
w wersji przypiętej i zwendorowanej w repozytorium (patrz `data/sgjp/`).
|
|
145
|
+
Zbudowane indeksy pokrywają 223 748 lematów / ~3,86 mln rekordów form i ważą
|
|
146
|
+
łącznie ~49 MB (`odmien.marisa` ≈ 24 MB, `podaj.marisa` ≈ 25 MB).
|
|
147
|
+
|
|
148
|
+
> Copyright © 2007–2026 Marcin Woliński, Zbigniew Bronk, Włodzimierz
|
|
149
|
+
> Gruszczyński, Witold Kieraś, Zygmunt Saloni, Danuta Skowrońska, Robert Wołosz.
|
|
150
|
+
|
|
151
|
+
SGJP jest udostępniany na licencji 2-clause BSD. Strona licencyjna:
|
|
152
|
+
<https://morfeusz.sgjp.pl/doc/license/en>. Pełny tekst i szczegóły atrybucji —
|
|
153
|
+
patrz [`NOTICE.md`](NOTICE.md) oraz `data/sgjp/LICENSE.sgjp`.
|
|
154
|
+
|
|
155
|
+
### Licencja
|
|
156
|
+
|
|
157
|
+
Dwie warstwy, jawnie rozdzielone:
|
|
158
|
+
|
|
159
|
+
- **Kod pakietu** — BSD-2-Clause, © Michał Pasternak (plik [`LICENSE`](LICENSE)).
|
|
160
|
+
- **Dane SGJP** — BSD-2-Clause, © autorzy wymienieni wyżej. Redystrybucja jest
|
|
161
|
+
dozwolona pod warunkiem zachowania noty copyright, tekstu licencji i atrybucji
|
|
162
|
+
(szczegóły w [`NOTICE.md`](NOTICE.md)).
|
|
163
|
+
|
|
164
|
+
### Ograniczenia (v1)
|
|
165
|
+
|
|
166
|
+
- Tylko **rzeczowniki**. Bez czasowników, przymiotników, liczebników.
|
|
167
|
+
- Bez guessera słów spoza słownika i bez analizy biegnącego tekstu
|
|
168
|
+
(segmentacji/tokenizacji) — świadome YAGNI.
|
|
169
|
+
- Zakres: 7 przypadków × 2 liczby, oba kierunki (`odmien` / `podaj`).
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 🇬🇧 English version
|
|
174
|
+
|
|
175
|
+
### Purpose
|
|
176
|
+
|
|
177
|
+
Turn `wydział` into `wydziału` (genitive), `wydziałowi` (dative), `wydziałów`
|
|
178
|
+
(genitive plural) — programmatically, without keeping inflected forms by hand.
|
|
179
|
+
`polish-inflection` declines Polish **nouns** across **7 cases × 2 numbers** in
|
|
180
|
+
both directions:
|
|
181
|
+
|
|
182
|
+
- generation: `lemma + case + number → form` (`odmien`);
|
|
183
|
+
- analysis (reverse): `form → [analyses]` (`podaj`).
|
|
184
|
+
|
|
185
|
+
### Why — a gap in the ecosystem
|
|
186
|
+
|
|
187
|
+
There was no lightweight, data-only, deployment-friendly way to decline Polish
|
|
188
|
+
nouns by case:
|
|
189
|
+
|
|
190
|
+
- **gettext / plural forms** solve a *different* problem — picking a variant by
|
|
191
|
+
**number** (`nplurals`), not declension by **case**. gettext has no concept of
|
|
192
|
+
grammatical case and cannot decline a noun.
|
|
193
|
+
- **Morfeusz 2** is the canonical Polish morphological analyzer and generator,
|
|
194
|
+
but ships a native C++ engine + SWIG bindings + a compiled dictionary of tens
|
|
195
|
+
of MB. Overkill and a container-deployment headache. Here there is no engine,
|
|
196
|
+
no SWIG and no dictionary compilation — just data and `pip install`.
|
|
197
|
+
- **pymorphy2 / pymorphy3** cover only Russian and Ukrainian — not Polish.
|
|
198
|
+
- **inflection / inflect / pyinflect** — English only.
|
|
199
|
+
|
|
200
|
+
Core idea: take the SGJP **data** (`form / lemma / tag` triples), drop the
|
|
201
|
+
**engine**. The set of forms is closed and fully enumerated — we generate
|
|
202
|
+
nothing at runtime, we only index and look up.
|
|
203
|
+
|
|
204
|
+
### Installation
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pip install polish-inflection
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The only runtime dependency is `marisa-trie` — a compiled C++ extension that
|
|
211
|
+
ships prebuilt binary wheels (Linux/macOS/Windows, cp39–cp313), so `pip install`
|
|
212
|
+
needs no compiler and no build tooling. The wheel ships prebuilt `.marisa`
|
|
213
|
+
indices — no SGJP required. The reader uses **mmap**: import does not load the
|
|
214
|
+
dictionary into RAM, and a single lookup pages in only O(word length) nodes
|
|
215
|
+
(~1 µs, measured RSS overhead ~1.5 MB instead of ~23 MB for a full load). The
|
|
216
|
+
indices cover 223,748 lemmas / ~3.86M form records and weigh ~49 MB total
|
|
217
|
+
(`odmien.marisa` ≈ 24 MB, `podaj.marisa` ≈ 25 MB).
|
|
218
|
+
|
|
219
|
+
### Examples — `odmien`
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from polish_inflection import odmien, DOPEŁNIACZ, MNOGA
|
|
223
|
+
|
|
224
|
+
odmien("wydział", DOPEŁNIACZ) # -> "wydziału"
|
|
225
|
+
odmien("wydział", DOPEŁNIACZ, MNOGA) # -> "wydziałów"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Out-of-dictionary behaviour is controlled by `default`:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from polish_inflection import (
|
|
232
|
+
odmien, odmien_lub_none, odmien_lub_wyraz, BrakOdmiany, DOPEŁNIACZ,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
odmien("wydział", DOPEŁNIACZ) # "wydziału"
|
|
236
|
+
odmien("qwerty", DOPEŁNIACZ) # raises BrakOdmiany
|
|
237
|
+
odmien_lub_none("qwerty", DOPEŁNIACZ) # None
|
|
238
|
+
odmien_lub_wyraz("qwerty", DOPEŁNIACZ) # "qwerty" (passthrough)
|
|
239
|
+
odmien("qwerty", DOPEŁNIACZ, default="—") # "—" (any fallback)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`odmien_warianty` returns all valid forms in a slot (variants).
|
|
243
|
+
|
|
244
|
+
### Examples — `podaj` (reverse direction)
|
|
245
|
+
|
|
246
|
+
Polish has syncretism (one form = many cases) and homography (one form = many
|
|
247
|
+
lemmas), so `podaj` returns a **list** of analyses:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from polish_inflection import podaj
|
|
251
|
+
|
|
252
|
+
podaj("jednostki")
|
|
253
|
+
# [Analiza(lemat='jednostka', przypadek='gen', liczba='sg', rodzaj='f'),
|
|
254
|
+
# Analiza(lemat='jednostka', przypadek='nom', liczba='pl', rodzaj='f'),
|
|
255
|
+
# Analiza(lemat='jednostka', przypadek='acc', liczba='pl', rodzaj='f'),
|
|
256
|
+
# Analiza(lemat='jednostka', przypadek='voc', liczba='pl', rodzaj='f')]
|
|
257
|
+
|
|
258
|
+
podaj("jednostki", liczba="pl") # narrow to plural
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`Analiza` is a lightweight `NamedTuple`: `(lemat, przypadek, liczba, rodzaj)`.
|
|
262
|
+
Constant names stay Polish (`MIANOWNIK`, `DOPEŁNIACZ`, …, `POJEDYNCZA`, `MNOGA`)
|
|
263
|
+
and map to SGJP tags (`nom`, `gen`, …, `sg`, `pl`).
|
|
264
|
+
|
|
265
|
+
### Data source and attribution
|
|
266
|
+
|
|
267
|
+
The inflectional data comes from the **Grammatical Dictionary of Polish (SGJP)**,
|
|
268
|
+
pinned and vendored in this repository (see `data/sgjp/`).
|
|
269
|
+
|
|
270
|
+
> Copyright © 2007–2026 Marcin Woliński, Zbigniew Bronk, Włodzimierz
|
|
271
|
+
> Gruszczyński, Witold Kieraś, Zygmunt Saloni, Danuta Skowrońska, Robert Wołosz.
|
|
272
|
+
|
|
273
|
+
SGJP is distributed under the 2-clause BSD license. License page:
|
|
274
|
+
<https://morfeusz.sgjp.pl/doc/license/en>. Full text and attribution details are
|
|
275
|
+
in [`NOTICE.md`](NOTICE.md) and `data/sgjp/LICENSE.sgjp`.
|
|
276
|
+
|
|
277
|
+
### License
|
|
278
|
+
|
|
279
|
+
Two clearly separated layers:
|
|
280
|
+
|
|
281
|
+
- **Package code** — BSD-2-Clause, © Michał Pasternak (see [`LICENSE`](LICENSE)).
|
|
282
|
+
- **SGJP data** — BSD-2-Clause, © the authors listed above. Redistribution is
|
|
283
|
+
permitted provided the copyright notice, license text and attribution are
|
|
284
|
+
retained (details in [`NOTICE.md`](NOTICE.md)).
|
|
285
|
+
|
|
286
|
+
### Limitations (v1)
|
|
287
|
+
|
|
288
|
+
- **Nouns only.** No verbs, adjectives or numerals.
|
|
289
|
+
- No out-of-dictionary guesser and no running-text analysis
|
|
290
|
+
(segmentation/tokenization) — deliberate YAGNI.
|
|
291
|
+
- Scope: 7 cases × 2 numbers, both directions (`odmien` / `podaj`).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
polish_inflection/__init__.py,sha256=FLtEN1xpgwMCVbjXMjWeCNZXn_ouzyqEdLihPuJuCDM,1079
|
|
2
|
+
polish_inflection/build.py,sha256=caKWROxu9byJ0wnWV5wYIHK870ZtASGzO7uoYHFjkiw,11783
|
|
3
|
+
polish_inflection/const.py,sha256=REPN0ZU3cbwu3Xk2ObvrUapIvX6AcA79ndukd8bwx5g,1089
|
|
4
|
+
polish_inflection/core.py,sha256=kxkc0Y5fGorS8LlMIGQTxEzCyYO7eM5bXead0XNPXNw,5387
|
|
5
|
+
polish_inflection/errors.py,sha256=dExGr0Is-ukrwyP1NYMEfsxWg_UPir2aaXXPKeA_zxc,601
|
|
6
|
+
polish_inflection/data/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
polish_inflection/data/BUILD_INFO.json,sha256=ZVhYVvZAHHdbSjqn3V-P8qJEGhldC48aIw-GV05lI1A,234
|
|
8
|
+
polish_inflection/data/odmien.marisa,sha256=KazL74jUzrjS22bXTkem0A1rkamFkmaB0sCvqah_tWU,24431696
|
|
9
|
+
polish_inflection/data/podaj.marisa,sha256=fLa2NQ6NaevYLgmObDqUus-WqPaPkQguFo1omEeYOiU,25000464
|
|
10
|
+
polish_inflection-0.1.0.dist-info/METADATA,sha256=nKUfcsoAoOgjA7p8gRStJCSMRPWOXryOpee7ioguwlg,11956
|
|
11
|
+
polish_inflection-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
polish_inflection-0.1.0.dist-info/entry_points.txt,sha256=NYsrpqKqDhRjqpz2GHAAEvP_BGtedwvyCkSZxh773ws,73
|
|
13
|
+
polish_inflection-0.1.0.dist-info/licenses/LICENSE,sha256=wC4FTktT4Qi-N1mkI5oaLMNCXXqFZi1hdfQMUdjan5Q,1306
|
|
14
|
+
polish_inflection-0.1.0.dist-info/licenses/NOTICE.md,sha256=pf3EkpRYcvrh5mZXAsPh9jBCIVGoDuw8BkoSVK673es,3050
|
|
15
|
+
polish_inflection-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Michał Pasternak
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# NOTICE — licencje i atrybucja
|
|
2
|
+
|
|
3
|
+
Pakiet `polish-inflection` łączy dwie warstwy o **osobnych** właścicielach praw
|
|
4
|
+
autorskich i osobnych (choć identycznych co do treści) licencjach. Obie są
|
|
5
|
+
2-clause BSD; obie wymagają zachowania noty copyright i tekstu licencji przy
|
|
6
|
+
redystrybucji.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Kod pakietu
|
|
11
|
+
|
|
12
|
+
Copyright © 2026 Michał Pasternak.
|
|
13
|
+
Licencja: **BSD-2-Clause**. Pełny tekst: plik [`LICENSE`](LICENSE) w tym repo.
|
|
14
|
+
|
|
15
|
+
Obejmuje kod źródłowy w `src/polish_inflection/` (runtime, build, CLI), testy
|
|
16
|
+
oraz dokumentację. **Nie** obejmuje danych fleksyjnych ani zbudowanych z nich
|
|
17
|
+
indeksów `.marisa` — te podlegają warstwie 2.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 2. Dane SGJP (Słownik gramatyczny języka polskiego)
|
|
22
|
+
|
|
23
|
+
Indeksy `.marisa` w `src/polish_inflection/data/` są zbudowane z danych
|
|
24
|
+
fleksyjnych SGJP i stanowią ich utwór zależny. Podlegają licencji i atrybucji
|
|
25
|
+
SGJP.
|
|
26
|
+
|
|
27
|
+
**Copyright © 2007–2026 Marcin Woliński, Zbigniew Bronk, Włodzimierz
|
|
28
|
+
Gruszczyński, Witold Kieraś, Zygmunt Saloni, Danuta Skowrońska, Robert Wołosz.**
|
|
29
|
+
|
|
30
|
+
Licencja: **BSD-2-Clause**.
|
|
31
|
+
Strona licencyjna: <https://morfeusz.sgjp.pl/doc/license/en>.
|
|
32
|
+
|
|
33
|
+
Autorytatywny, verbatim tekst licencji jadący z konkretnym wydaniem SGJP
|
|
34
|
+
znajduje się w pliku **`data/sgjp/LICENSE.sgjp`** (wyodrębniony z nagłówka
|
|
35
|
+
`#<COPYRIGHT>` pobranego pliku `.tab.gz` przez komendę `refresh-sgjp`). W razie
|
|
36
|
+
jakiejkolwiek rozbieżności rozstrzygający jest tamten plik, nie ten dokument.
|
|
37
|
+
|
|
38
|
+
### Warunki 2-clause BSD (cytat)
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Redistribution and use in source and binary forms, with or without
|
|
42
|
+
modification, are permitted provided that the following conditions are met:
|
|
43
|
+
|
|
44
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
45
|
+
list of conditions and the following disclaimer.
|
|
46
|
+
|
|
47
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
48
|
+
this list of conditions and the following disclaimer in the documentation
|
|
49
|
+
and/or other materials provided with the distribution.
|
|
50
|
+
|
|
51
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
52
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
53
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
54
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
55
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
56
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
57
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
58
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
59
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
60
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Konsekwencje redystrybucji
|
|
64
|
+
|
|
65
|
+
Redystrybuując `polish-inflection` (w tym wheel/sdist zawierające `.marisa`),
|
|
66
|
+
zachowujesz notę copyright SGJP, powyższe warunki i atrybucję — co realizuje ten
|
|
67
|
+
plik `NOTICE.md` (dołączany do dystrybucji) wraz z verbatim `data/sgjp/LICENSE.sgjp`
|
|
68
|
+
podróżującym z danymi w repozytorium.
|