glyph-cli 0.2.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.
glyph_cli/script.py ADDED
@@ -0,0 +1,272 @@
1
+ """The script itself: parts, words and the vocabulary that names them.
2
+
3
+ A *part* is a 3x3 shape with a meaning. A *word* is two lattice positions,
4
+ a **kind** on the left and a **which** on the right, written ``KIND.WHICH``.
5
+ Either half may be empty (``_``). Numbers are ``COUNT.<n>`` (0 to 511): COUNT is the
6
+ only kind whose which is read as nine bits, and it never takes a part as its which,
7
+ so a number can never be drawn like a word. ``COUNT`` alone is zero.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from importlib import resources
16
+ from pathlib import Path
17
+
18
+ #: A half of a word: a part name, a number (only after ``ONE``) or ``None`` for empty.
19
+ Half = str | int | None
20
+
21
+ MAX_NUMBER = 511
22
+ EMPTY = "_"
23
+ NUMBER = "COUNT" # the kind whose which is a number
24
+
25
+
26
+ class GlyphError(Exception):
27
+ """A problem the user can fix: a bad word, a bad page, a bad vocabulary."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Part:
32
+ name: str
33
+ shape: tuple[str, str, str]
34
+ thing: str
35
+ relation: str | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Word:
40
+ """One word: ``kind`` and ``which``. ``Word(None, None)`` is the empty word."""
41
+
42
+ kind: str | None = None
43
+ which: Half = None
44
+
45
+ @property
46
+ def is_empty(self) -> bool:
47
+ return self.kind is None and self.which is None
48
+
49
+ @property
50
+ def is_number(self) -> bool:
51
+ return isinstance(self.which, int)
52
+
53
+ @property
54
+ def halves(self) -> tuple[Half, Half]:
55
+ return (self.kind, self.which)
56
+
57
+ def __str__(self) -> str:
58
+ if self.is_empty:
59
+ return EMPTY
60
+ which = "" if self.which is None else f".{self.which}"
61
+ return (self.kind or EMPTY) + which
62
+
63
+
64
+ @dataclass
65
+ class Entry:
66
+ """A vocabulary entry: a word with an agreed meaning."""
67
+
68
+ word: Word
69
+ gloss: str
70
+ domain: str = "Unsorted"
71
+ note: str = ""
72
+
73
+ def to_json(self) -> dict:
74
+ return {
75
+ "kind": self.word.kind,
76
+ "which": self.word.which,
77
+ "gloss": self.gloss,
78
+ "domain": self.domain,
79
+ "note": self.note,
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class Vocabulary:
85
+ parts: dict[str, Part]
86
+ markers: dict[str, str]
87
+ entries: list[Entry] = field(default_factory=list)
88
+ version: int = 1
89
+ path: Path | None = None
90
+
91
+ # ---- loading and saving ----
92
+
93
+ @classmethod
94
+ def from_json(cls, data: dict, path: Path | None = None) -> Vocabulary:
95
+ try:
96
+ parts = {
97
+ name: Part(name, tuple(p["shape"]), p["thing"], p.get("relation")) for name, p in data["parts"].items()
98
+ }
99
+ entries = [
100
+ Entry(Word(e["kind"], e["which"]), e["gloss"], e.get("domain", "Unsorted"), e.get("note", ""))
101
+ for e in data["words"]
102
+ ]
103
+ return cls(parts, dict(data.get("markers", {})), entries, data.get("version", 1), path)
104
+ except (KeyError, TypeError) as exc:
105
+ raise GlyphError(f"not a glyph vocabulary file: missing {exc}") from exc
106
+
107
+ @classmethod
108
+ def load(cls, path: str | os.PathLike | None = None) -> Vocabulary:
109
+ """Load a vocabulary file, or the bundled one when ``path`` is ``None``."""
110
+ if path is None:
111
+ text = resources.files("glyph_cli").joinpath("data/vocab.json").read_text(encoding="utf-8")
112
+ return cls.from_json(json.loads(text))
113
+ p = Path(path)
114
+ try:
115
+ data = json.loads(p.read_text(encoding="utf-8"))
116
+ except FileNotFoundError:
117
+ raise GlyphError(f"vocabulary file not found: {p}") from None
118
+ except json.JSONDecodeError as exc:
119
+ raise GlyphError(f"{p} is not valid JSON: {exc}") from None
120
+ return cls.from_json(data, p)
121
+
122
+ def to_json(self) -> dict:
123
+ return {
124
+ "version": self.version,
125
+ "parts": {
126
+ p.name: {"shape": list(p.shape), "thing": p.thing, "relation": p.relation} for p in self.parts.values()
127
+ },
128
+ "markers": self.markers,
129
+ "words": [e.to_json() for e in self.entries],
130
+ }
131
+
132
+ def save(self, path: str | os.PathLike | None = None) -> Path:
133
+ target = Path(path) if path is not None else self.path
134
+ if target is None:
135
+ raise GlyphError(
136
+ "the bundled vocabulary is read-only; run `glyph init` to make an editable copy, "
137
+ "then point to it with --vocab or GLYPH_VOCAB"
138
+ )
139
+ tmp = target.with_name(target.name + ".tmp")
140
+ tmp.write_text(json.dumps(self.to_json(), indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
141
+ os.replace(tmp, target)
142
+ self.path = target
143
+ return target
144
+
145
+ # ---- words ----
146
+
147
+ def parse(self, text: str) -> Word:
148
+ """``'BODY.OTHER'`` -> ``Word('BODY', 'OTHER')``; ``'COUNT.137'`` -> ``Word('COUNT', 137)``."""
149
+ s = text.strip()
150
+ if s in ("", EMPTY):
151
+ return Word()
152
+ k, _, w = s.partition(".")
153
+ kind = None if k in ("", EMPTY) else k.upper()
154
+ which: Half
155
+ if w in ("", EMPTY):
156
+ which = None
157
+ elif w.isdigit():
158
+ which = int(w)
159
+ if which > MAX_NUMBER:
160
+ raise GlyphError(f"number out of range 0-{MAX_NUMBER}: {which}")
161
+ if kind not in (NUMBER, None):
162
+ raise GlyphError(f"a number needs {NUMBER} as its kind, e.g. {NUMBER}.{which} (got {s})")
163
+ if kind == NUMBER and which == 0:
164
+ which = None # COUNT alone is zero: COUNT.0 draws exactly like it
165
+ else:
166
+ which = w.upper()
167
+ if kind == NUMBER:
168
+ raise GlyphError(f"{NUMBER} only takes a number as its which, e.g. {NUMBER}.12 (got {s})")
169
+ for half in (kind, which):
170
+ if isinstance(half, str) and half not in self.parts:
171
+ raise GlyphError(f"unknown part: {half} (see `glyph parts`)")
172
+ return Word(kind, which)
173
+
174
+ def lookup(self, word: Word) -> Entry | None:
175
+ for e in self.entries:
176
+ if e.word == word:
177
+ return e
178
+ return None
179
+
180
+ def thing(self, half: Half) -> str:
181
+ """The meaning of one half on its own."""
182
+ if half is None:
183
+ return EMPTY
184
+ if isinstance(half, int):
185
+ return str(half)
186
+ return self.parts[half].thing
187
+
188
+ def gloss(self, word: Word, role: str = "node") -> str:
189
+ """The best human reading of a word as a ``node`` or as a ``relation``."""
190
+ if word.is_empty:
191
+ return "—"
192
+ if role == "relation":
193
+ base = ""
194
+ if word.kind:
195
+ part = self.parts[word.kind]
196
+ base = part.relation or part.thing
197
+ if word.which is None:
198
+ return base
199
+ marker = self.markers.get(str(word.which), str(word.which))
200
+ return f"{base} [{marker}]"
201
+ if word.kind == NUMBER and (word.is_number or word.which is None):
202
+ return str(word.which or 0)
203
+ entry = self.lookup(word)
204
+ if entry:
205
+ return entry.gloss
206
+ if word.which is None:
207
+ return self.parts[word.kind].thing # type: ignore[index]
208
+ return f"{self.thing(word.kind)} | {self.thing(word.which)} (not in vocabulary)"
209
+
210
+ # ---- editing ----
211
+
212
+ def add(self, word: Word, gloss: str, domain: str = "Unsorted", note: str = "", force: bool = False) -> Entry:
213
+ if word.kind is None or word.is_number or word.kind == NUMBER:
214
+ raise GlyphError(f"a vocabulary word needs a kind; numbers ({NUMBER}.n) are built in")
215
+ existing = self.lookup(word)
216
+ if existing:
217
+ raise GlyphError(f'already in the vocabulary: {word} = "{existing.gloss}"')
218
+ same = [e for e in self.entries if e.gloss.lower() == gloss.lower()]
219
+ if same and not force:
220
+ raise GlyphError(f'the meaning "{gloss}" is already used by {same[0].word}; use --force to add anyway')
221
+ entry = Entry(word, gloss, domain, note)
222
+ self.entries.append(entry)
223
+ return entry
224
+
225
+ def remove(self, word: Word) -> Entry:
226
+ entry = self.lookup(word)
227
+ if not entry:
228
+ raise GlyphError(f"not in the vocabulary: {word}")
229
+ self.entries.remove(entry)
230
+ return entry
231
+
232
+ def search(self, text: str) -> tuple[list[Entry], list[Part]]:
233
+ t = text.lower()
234
+ words = [e for e in self.entries if t in e.gloss.lower() or t in e.note.lower()]
235
+ parts = [p for p in self.parts.values() if t in p.thing.lower() or (p.relation and t in p.relation.lower())]
236
+ return words, parts
237
+
238
+ def validate(self) -> list[str]:
239
+ """Return a list of problems; an empty list means the vocabulary is sound."""
240
+ errors: list[str] = []
241
+ shapes: dict[tuple[str, ...], str] = {}
242
+ for p in self.parts.values():
243
+ if len(p.shape) != 3 or any(len(r) != 3 or set(r) - set("#.") for r in p.shape):
244
+ errors.append(f'part {p.name}: shape must be 3 rows of 3 "#"/"."')
245
+ if p.shape in shapes:
246
+ errors.append(f"parts {shapes[p.shape]} and {p.name} have the same shape")
247
+ shapes[p.shape] = p.name
248
+ if not any("#" in r for r in p.shape):
249
+ errors.append(f"part {p.name}: shape is empty")
250
+ seen: dict[Word, str] = {}
251
+ glosses: dict[str, Word] = {}
252
+ for e in self.entries:
253
+ for half in e.word.halves:
254
+ if isinstance(half, str) and half not in self.parts:
255
+ errors.append(f"{e.word}: unknown part {half}")
256
+ if e.word.kind == NUMBER:
257
+ errors.append(f"{e.word}: {NUMBER} words are numbers and are built in")
258
+ if e.word in seen:
259
+ errors.append(f'{e.word} defined twice ("{seen[e.word]}" and "{e.gloss}")')
260
+ seen[e.word] = e.gloss
261
+ g = e.gloss.lower()
262
+ if g in glosses:
263
+ errors.append(f'meaning "{e.gloss}" used by {glosses[g]} and {e.word}')
264
+ glosses[g] = e.word
265
+ return errors
266
+
267
+ def domains(self) -> list[str]:
268
+ out: list[str] = []
269
+ for e in self.entries:
270
+ if e.domain not in out:
271
+ out.append(e.domain)
272
+ return out
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: glyph-cli
3
+ Version: 0.2.0
4
+ Summary: The dictionary and toolkit for the grid script of Ross 128 b (InterImm, The Contact Era).
5
+ Project-URL: Homepage, https://github.com/InterImm/glyph-cli
6
+ Project-URL: Documentation, https://interimm.github.io/glyph-cli/
7
+ Project-URL: Source, https://github.com/InterImm/glyph-cli
8
+ Project-URL: Issues, https://github.com/InterImm/glyph-cli/issues
9
+ Author: InterImm
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: conlang,interimm,knowledge graph,science fiction,writing system
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Other Audience
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Text Processing :: Linguistic
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+
27
+ # glyph
28
+
29
+ The dictionary and toolkit for the **grid script**: how humans write down what Ross 128 b sends, in InterImm's *The Contact Era*.
30
+
31
+ **Docs:** https://interimm.github.io/glyph-cli/
32
+
33
+ ```sh
34
+ uvx --from glyph-cli glyph show BODY.OTHER STAR.TIME COUNT.137
35
+ ```
36
+
37
+ ```text
38
+ +++.+++ .+..+.+ .....+.
39
+ +++.+.. +++.+.+ ......+
40
+ +++.+++ .+..+.+ +++...+
41
+ BODY.OTHER: your world
42
+ STAR.TIME: pulsar
43
+ COUNT.137: 137
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ uv tool install glyph-cli # or: pipx install glyph-cli, or: pip install glyph-cli
50
+ ```
51
+
52
+ Python 3.10 or newer, no other dependencies.
53
+
54
+ ## Use
55
+
56
+ ```sh
57
+ glyph parts # the 16 parts
58
+ glyph find wind # search meanings
59
+ glyph check BODY.STAR # is it a word? exit 1 if not
60
+ glyph show BODY.OTHER --symbol × # draw words
61
+ glyph render examples/third-voice.txt # draw a page
62
+ glyph render examples/third-voice.txt --svg -o page.svg
63
+ glyph graph examples/conversation.txt # read it as a knowledge graph (--json, --dot)
64
+ glyph render examples/question.txt | glyph decode - # and back
65
+ glyph init my-vocab.json && export GLYPH_VOCAB=$PWD/my-vocab.json
66
+ glyph add BODY.STAR "a star-world" --domain Worlds
67
+ ```
68
+
69
+ A page file has one band per line, `SYMBOL: node | relation | node`; a blank line starts the next statement. See [`examples/`](https://github.com/InterImm/glyph-cli/tree/main/examples) and the [docs](https://interimm.github.io/glyph-cli/pages/).
70
+
71
+ ## Develop
72
+
73
+ ```sh
74
+ uv sync --all-groups # or: pip install -e . pytest ruff zensical
75
+ uv run pytest
76
+ uv run ruff check . && uv run ruff format --check .
77
+ uv run python scripts/gen_docs.py # regenerate docs/vocabulary.md, docs/cli.md and the example drawings
78
+ uv run zensical serve # docs at http://localhost:8000
79
+ ```
80
+
81
+ The vocabulary ships in [`src/glyph_cli/data/vocab.json`](https://github.com/InterImm/glyph-cli/blob/main/src/glyph_cli/data/vocab.json). Change it with the tool (`glyph --vocab src/glyph_cli/data/vocab.json add ...`), then run `scripts/gen_docs.py`; the tests fail if the generated docs are stale.
82
+
83
+ Pushes to `main` publish the docs to GitHub Pages.
84
+
85
+ ## Release
86
+
87
+ 1. Bump `__version__` in `src/glyph_cli/__init__.py` and merge it to `main`.
88
+ 2. Publish a GitHub release tagged `vX.Y.Z` (the same version).
89
+
90
+ The release workflow checks the tag matches the version, builds the package, tests the built wheel and uploads it to [PyPI](https://pypi.org/project/glyph-cli/) with Trusted Publishing, so no token is stored.
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,14 @@
1
+ glyph_cli/__init__.py,sha256=yDgWZPf2YOoAtryLnfNOtWPynJ9JwGdWREHnGS7xAXU,241
2
+ glyph_cli/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ glyph_cli/cli.py,sha256=JVMrbzyR8bDj1ALwbzeRyQJcwh8bjLVhxM9pc4dhhko,9863
4
+ glyph_cli/drawing.py,sha256=1sIUJyoOaAKavU2kirHXierxubdJZAamK9wQ952mrUU,7936
5
+ glyph_cli/export.py,sha256=_UFvvBUqX4kzHWVC5tpjd_R_xl2yuYPYiRGV5XC9oMI,1163
6
+ glyph_cli/graph.py,sha256=NQVWCWh7f4tMM2HDEDHs3atiNqflWMaSis-YFuEo7jw,4809
7
+ glyph_cli/page.py,sha256=4j7TRPeSYxvWLaIdb-VzO7PCtrjYGD-AGySWyJXACDU,3679
8
+ glyph_cli/script.py,sha256=3HbGqX7kdbPmkFwPEl6sSoN6qbkeRsR89p-LOg2MdzE,10252
9
+ glyph_cli/data/vocab.json,sha256=-s-zYZwVd6qHx78NUmKp9XeH2ksULE9dNu-hXEmwKDQ,8764
10
+ glyph_cli-0.2.0.dist-info/METADATA,sha256=6NGqwzfgYnS999LflMye3SModtyDSJH2qp0m_akyreM,3664
11
+ glyph_cli-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ glyph_cli-0.2.0.dist-info/entry_points.txt,sha256=CmNHQcMaQCjYbHPNpq0Qo5fY_sv_e-QBtirLrQzfdW8,45
13
+ glyph_cli-0.2.0.dist-info/licenses/LICENSE,sha256=BNbLWEVBqHWUAMGfNbrk-ZHxjbF5NbsszdU3IYiFZko,1065
14
+ glyph_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ glyph = glyph_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 InterImm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.