Skip to content

Commit 8f3de93

Browse files
committed
0.2.0: direct .imp DSL engine (replaces pre-compiled Python map modules)
- parser.py: .imp DSL parser (metadata, tests, dependencies, stages, parallels) - expr.py: expression layer (any/range/list/maybe/anchors/concat/guards) - engine.py: executor with longest-match parallels, word-aware casing, dependency-aliased dotted run targets - 98/98 on un-bul; 206/242 on un-ell (posix guards pending) - No monorepo bootstrap required; point add_load_path at the map corpus
1 parent 068da69 commit 8f3de93

8 files changed

Lines changed: 750 additions & 81 deletions

File tree

README.adoc

Lines changed: 59 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,72 +2,84 @@
22

33
== Purpose
44

5-
This repository contains code for the Interscript Python runtime ("Interscript-Python").
5+
The official Python runtime for Interscript — deterministic transliteration
6+
over the interscript map corpus (300+ authority-backed systems: BGN/PCGN,
7+
ISO, UN, ALA-LC, ODNI, ICAO, DIN, and others).
68

7-
This software allows performing script conversions by using the
8-
https://github.com/interscript/maps[default set of Interscript maps]
9-
hosted at GitHub.
9+
This version (0.2.0) parses the `.imp` map DSL *directly* — no
10+
pre-compiled Python map modules or monorepo bootstrap required. Point
11+
it at the map corpus and go.
1012

11-
Interscript is a project for interoperable script conversion systems
12-
and provides executable runtimes for multiple platforms.
13-
Full documentation available https://github.com/interscript/interscript/[here].
13+
Maps that need vocalized input (undiacritized Arabic, nikud-less Hebrew,
14+
unsegmented Thai) dispatch to a
15+
https://www.secryst.org[secryst crystal] through the optional phonological
16+
layer; see the phonological-layer documentation on
17+
https://www.interscript.org[our site].
1418

15-
== Integration
16-
17-
This section provides instructions on how to utilize Interscript-Python
18-
with your application.
19-
20-
Interscript-Python can be used as a Python library
21-
22-
=== Configuration
19+
== Install
2320

2421
[source,shell]
2522
----
26-
$ pip install interscript
23+
pip install interscript
2724
----
2825

2926
== Usage
3027

31-
[source,javascript]
32-
-----
28+
[source,python]
29+
----
3330
import interscript
34-
interscript.load_map('bgnpcgn-ukr-Cyrl-Latn-2019')
35-
print(interscript.transliterate('bgnpcgn-ukr-Cyrl-Latn-2019', input()))
36-
-----
3731
38-
== Development
32+
interscript.add_load_path(".../interscript/maps/maps")
33+
interscript.transliterate("un-bul-Cyrl-Latn-1977", "нос Бяга БЯГА")
34+
# -> "nos Byaga BYAGA"
35+
----
3936

40-
Ensure you have used a bootstrap repository https://github.com/interscript/interscript
41-
and not just cloned this repo yourself, otherwise `./setup.sh` script won\'t work.
37+
== API
4238

43-
`./setup.sh` script is used to build the maps from the `maps` repository using our Ruby
44-
Interscript implementation. Those maps are compiled to respective `.py` files inside
45-
`src/interscript/maps/` directory and are not included in this repository.
39+
- `add_load_path(path)` — register a directory containing `.imp`/`.isc` map files
40+
- `map_exist(name)` — check whether a map is available
41+
- `map_list()` — list all discoverable map names
42+
- `load_map(name)` — parse and cache a map (returns an `Engine`)
43+
- `transliterate(name, text)` — apply a map to text
4644

47-
=== Running tests
45+
== Measured coverage (2026-08-20)
4846

49-
[source,shell]
50-
---
51-
$ pip install regex pytest
52-
$ ./build.sh
53-
$ pip install -e .
54-
$ pytest
55-
---
47+
The engine passes maps' own embedded tests with the following scores;
48+
remaining gaps are documented in the test suite:
5649

57-
=== Building package
50+
|===
51+
|map |embedded tests |gap
5852

59-
[source,shell]
60-
---
61-
$ pip install regex pytest
62-
$ ./build.sh
63-
$ python -m build
64-
---
53+
|un-bul-Cyrl-Latn-1977
54+
|98/98
55+
|none
56+
57+
|un-ell-Grek-Latn-1987-ts
58+
|206/242
59+
|posix letter-class guards
60+
61+
|bgnpcgn-prs-Arab-Latn-2007
62+
|loads, runs
63+
|proper-noun capitalization
64+
|===
65+
66+
== Architecture
67+
68+
- `parser.py` — `.imp` DSL parser: metadata (incl. `|` block scalars), tests,
69+
dependency declarations, stages, parallel groups, sub expressions
70+
- `expr.py` — expression layer: `any("…")` char classes, `any("a".."z")`
71+
ranges, `any(["x","y"])` alternations, `maybe("…")`, `space`, `boundary`,
72+
`line_start`/`line_end`, `+` concatenation, `before:`/`after:` context guards
73+
- `engine.py` — executor: parallel subs (longest-match-wins + word-aware
74+
casing), `subst` regex, `run` (dependency-aliased dotted targets),
75+
compose/decompose, downcase/upcase/titlecase
6576

66-
=== Publishing package
77+
== Sibling runtimes
6778

68-
Edit pyproject.toml to contain a new version number, create a commit
69-
and add a git tag with that number.
79+
- Ruby: `gem install interscript` (https://rubygems.org/gems/interscript)
80+
- npm: `npm install interscript` (https://www.npmjs.com/package/interscript)
81+
- This: `pip install interscript`
7082

71-
== Copyright and license
83+
== License
7284

73-
This is a Ribose project. Copyright Ribose.
85+
BSD-2-Clause. See link:LICENSE.adoc[LICENSE.adoc].

pyproject.toml

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
15
[project]
26
name = "interscript"
3-
version = "0.1.0"
7+
version = "0.2.0"
48
authors = [
59
{ name="Ribose Inc.", email="open.source@ribose.com" },
10+
{ name="Interscript contributors" },
611
]
7-
description = "Interoperable script conversion systems"
12+
description = "Interoperable script conversion systems — deterministic transliteration over .imp maps"
813
readme = {file = "README.adoc", content-type = "text/plain"}
9-
requires-python = ">=3.8"
14+
requires-python = ">=3.10"
1015
classifiers = [
1116
"Programming Language :: Python :: 3",
1217
"License :: OSI Approved :: BSD License",
@@ -16,12 +21,14 @@ classifiers = [
1621
"Intended Audience :: Education",
1722
"Topic :: Text Processing :: Linguistic",
1823
]
19-
dependencies = ["regex"]
24+
dependencies = []
2025

2126
[project.urls]
2227
Homepage = "https://www.interscript.org"
23-
Issues = "https://github.com/interscript/interscript-python/issues"
28+
Issues = "https://github.com/interscript/interscript-py/issues"
2429

25-
[build-system]
26-
requires = ["setuptools", "wheel", "regex"]
27-
build-backend = "setuptools.build_meta"
30+
[project.optional-dependencies]
31+
dev = ["pytest>=7"]
32+
33+
[tool.setuptools.packages.find]
34+
where = ["src"]

src/interscript/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
1-
from .interscript import *
1+
"""Interscript Python runtime — deterministic transliteration over .imp maps."""
2+
from .interscript import (
3+
add_load_path, map_exist, map_list, load_map, transliterate,
4+
Engine, ExecutionError, parse_file,
5+
)
6+
7+
__version__ = "0.2.0"
8+
__all__ = [
9+
"add_load_path", "map_exist", "map_list", "load_map", "transliterate",
10+
"Engine", "ExecutionError", "parse_file",
11+
]

src/interscript/engine.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Executor for parsed Interscript maps.
2+
3+
Semantics follow interscript-ruby for the covered op set:
4+
- parallel { sub a, b }: ONE pass over the text; at each position the
5+
longest matching pattern wins. Uppercase source characters map to
6+
uppercased results (я->ya implies Я->YA).
7+
- subst /pat/, res: regex substitution ($1 backreferences converted).
8+
- run "map": apply another map's stages to the whole text.
9+
- compose/decompose: NFC/NFD. downcase/upcase/titlecase: Unicode casing.
10+
11+
Unsupported constructs raise ExecutionError (on_unsupported="raise",
12+
default) or are skipped and recorded (on_unsupported="skip").
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import re
18+
import unicodedata
19+
20+
from .expr import expr_to_literal, expr_to_regex, is_plain_string
21+
22+
23+
class ExecutionError(ValueError):
24+
"""The map uses a construct this engine does not implement yet."""
25+
26+
27+
def _compile_parallel(subs: list[dict]) -> tuple[re.Pattern[str], dict[str, str], dict[str, str]]:
28+
"""Compile one parallel group: longest-pattern-first alternation
29+
with a named group per sub; lookaround guards for before:/after:.
30+
Plain-string patterns additionally feed the casing maps."""
31+
indexed = []
32+
anchor_results: dict[str, str] = {}
33+
n = len(subs)
34+
for i, sub in enumerate(subs):
35+
pat = expr_to_regex(sub["pattern"])
36+
full = pat
37+
if sub.get("before"):
38+
full = "(?<=" + expr_to_regex(sub["before"]) + ")" + full
39+
if sub.get("after"):
40+
full = full + "(?=" + expr_to_regex(sub["after"]) + ")"
41+
indexed.append((pat, full, f"s{i}"))
42+
if is_plain_string(sub["pattern"]) and not sub.get("before") and not sub.get("after"):
43+
src = expr_to_literal(sub["pattern"])
44+
if src.upper() != src:
45+
anchor_results[f"a{i}"] = expr_to_literal(sub["result"])
46+
indexed.append((re.escape(src.upper()), re.escape(src.upper()), f"a{i}"))
47+
indexed.sort(key=lambda t: -len(t[0]))
48+
combined = "|".join(f"(?P<{name}>{full})" for _, full, name in indexed)
49+
pattern = re.compile(combined) if indexed else re.compile(r"(?!)")
50+
51+
results = {f"s{i}": expr_to_literal(sub["result"]) for i, sub in enumerate(subs)}
52+
results.update(anchor_results)
53+
casing_map: dict[str, str] = {}
54+
upper_dst: dict[str, str] = {}
55+
for sub in subs:
56+
if is_plain_string(sub["pattern"]) and not sub.get("before") and not sub.get("after"):
57+
src = expr_to_literal(sub["pattern"])
58+
dst = expr_to_literal(sub["result"])
59+
casing_map[src] = dst
60+
if src.upper() != src:
61+
upper_dst[src.upper()] = dst
62+
return pattern, {"casing": casing_map, "upper": upper_dst, "results": results}, {}
63+
64+
65+
class Engine:
66+
def __init__(self, tree: dict, loader=None, on_unsupported: str = "raise") -> None:
67+
self.tree = tree
68+
self.metadata = tree.get("metadata", {})
69+
self._loader = loader
70+
self.on_unsupported = on_unsupported
71+
self.skipped_unsupported: list[str] = []
72+
self._compiled: re.Pattern[str] | None = None
73+
self._compiled_map: dict[str, str] = {}
74+
self._group_results: dict[str, str] = {}
75+
self._compiled_source: int | None = None
76+
77+
def transliterate(self, text: str) -> str:
78+
for stage in self.tree.get("stages", []):
79+
text = self._run_stage(stage, text)
80+
return text
81+
82+
def _run_stage(self, stage: dict, text: str) -> str:
83+
for child in stage.get("children", []):
84+
text = self._run_op(child, text)
85+
return text
86+
87+
def _group_repl(self, m: re.Match[str], text: str) -> str:
88+
name = m.lastgroup if m.lastgroup else ""
89+
if name in self._group_results:
90+
result = self._group_results[name]
91+
tok = m.group(0)
92+
if result != result.upper() and tok == tok.upper() and tok != tok.lower():
93+
ws, we = m.start(), m.end()
94+
while ws > 0 and text[ws - 1].isalpha():
95+
ws -= 1
96+
while we < len(text) and text[we].isalpha():
97+
we += 1
98+
if text[ws:we].isupper():
99+
return result.upper()
100+
return result
101+
return self._parallel_repl(m, text)
102+
103+
def _parallel_repl(self, m: re.Match[str], text: str) -> str:
104+
tok = m.group(0)
105+
dst = self._compiled_map.get(tok) or self._upper_dst.get(tok)
106+
if dst is None:
107+
return tok
108+
# interscript-ruby casing convention: inside an ALL-CAPS source
109+
# word, a fully-uppercase source token uppercases its result
110+
# (Я -> Ya normally, YA inside БЯГА).
111+
if dst != dst.upper() and tok == tok.upper() and tok != tok.lower():
112+
ws, we = m.start(), m.end()
113+
while ws > 0 and text[ws - 1].isalpha():
114+
ws -= 1
115+
while we < len(text) and text[we].isalpha():
116+
we += 1
117+
if text[ws:we].isupper():
118+
return dst.upper()
119+
return dst
120+
121+
def _run_op(self, op: dict, text: str) -> str:
122+
kind = op.get("kind")
123+
if kind == "parallel":
124+
if self._compiled is None or self._compiled_source != id(op):
125+
pattern, maps, _ = _compile_parallel(op["subs"])
126+
self._compiled = pattern
127+
self._compiled_map = maps["casing"]
128+
self._upper_dst = maps["upper"]
129+
self._group_results = maps["results"]
130+
self._compiled_source = id(op)
131+
return self._compiled.sub(lambda m: self._group_repl(m, text), text)
132+
if kind == "subst":
133+
flags = re.IGNORECASE if op.get("ignore_case") else 0
134+
pattern = re.compile(op["pattern"], flags)
135+
result = re.sub(r"\$(\d)", r"\\\1", op["result"])
136+
return pattern.sub(result, text)
137+
if kind == "run":
138+
target = op["map"]
139+
if target.startswith("map."):
140+
# dotted dependency reference: map.<alias>.stage.<name>
141+
alias = target.split(".")[1]
142+
deps = {
143+
d.get("alias") or d["name"]: d["name"]
144+
for d in self.tree.get("dependencies", [])
145+
if isinstance(d, dict)
146+
}
147+
if alias not in deps:
148+
raise ExecutionError(f"run {target!r}: unknown dependency alias")
149+
target = deps[alias]
150+
if self._loader is None:
151+
raise ExecutionError(f"run {op['map']!r}: no map loader configured")
152+
return self._loader(target).transliterate(text)
153+
if kind == "downcase":
154+
return text.lower()
155+
if kind == "upcase":
156+
return text.upper()
157+
if kind == "titlecase":
158+
return text.title()
159+
if kind == "compose":
160+
return unicodedata.normalize("NFC", text)
161+
if kind == "decompose":
162+
return unicodedata.normalize("NFD", text)
163+
what = op.get("what", kind)
164+
if self.on_unsupported == "skip":
165+
self.skipped_unsupported.append(str(what))
166+
return text
167+
raise ExecutionError(f"unsupported construct: {what!r}")

0 commit comments

Comments
 (0)