pycg3 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.
pycg3/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """pycg3 - A pure Python implementation of VISL CG-3 (Constraint Grammar 3)."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from pycg3.tag import TagPattern, is_wordform, is_baseform
6
+ from pycg3.cohort import Cohort, Reading, SingleWindow
7
+ from pycg3.set_def import SetDefinition, CompositeTag
8
+ from pycg3.context import ContextualTest
9
+ from pycg3.rule import Rule
10
+ from pycg3.grammar import Grammar, GrammarParser
11
+ from pycg3.stream import parse_stream
12
+ from pycg3.engine import Disambiguator
13
+ from pycg3.pipeline import Cg, CgWord, analyses_to_cg, analysis_to_cg
14
+
15
+ __all__ = [
16
+ "__version__",
17
+ "TagPattern",
18
+ "is_wordform",
19
+ "is_baseform",
20
+ "Cohort",
21
+ "Reading",
22
+ "SingleWindow",
23
+ "SetDefinition",
24
+ "CompositeTag",
25
+ "ContextualTest",
26
+ "Rule",
27
+ "Grammar",
28
+ "GrammarParser",
29
+ "parse_stream",
30
+ "Disambiguator",
31
+ "Cg",
32
+ "CgWord",
33
+ "analyses_to_cg",
34
+ "analysis_to_cg",
35
+ ]
pycg3/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ """Command-line interface compatible with vislcg3."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pycg3 import __version__
6
+ from pycg3.grammar import GrammarParser
7
+ from pycg3.engine import Disambiguator
8
+
9
+
10
+ def main(args=None):
11
+ parser = argparse.ArgumentParser(
12
+ prog="pycg3",
13
+ description="A pure Python implementation of VISL CG-3 (Constraint Grammar 3)"
14
+ )
15
+ parser.add_argument(
16
+ "-g", "--grammar",
17
+ required=True,
18
+ help="Grammar file to load and apply."
19
+ )
20
+ parser.add_argument(
21
+ "-i", "-I", "--input",
22
+ dest="input_file",
23
+ default=None,
24
+ help="Input file to read (default: stdin)."
25
+ )
26
+ parser.add_argument(
27
+ "-o", "-O", "--output",
28
+ dest="output_file",
29
+ default=None,
30
+ help="Output file to write (default: stdout)."
31
+ )
32
+ parser.add_argument(
33
+ "-e", "--show-rules",
34
+ action="store_true",
35
+ help="Mark modified/selected readings with <<<."
36
+ )
37
+ parser.add_argument(
38
+ "-t", "--trace",
39
+ action="store_true",
40
+ help="Provide trace output of rules that modified/selected readings."
41
+ )
42
+ parser.add_argument(
43
+ "-p", "--pipe-deleted",
44
+ action="store_true",
45
+ help="Output deleted readings prefixed with ;."
46
+ )
47
+ parser.add_argument(
48
+ "-s", "--sections",
49
+ type=int,
50
+ default=None,
51
+ help="Number of sections to execute."
52
+ )
53
+ parser.add_argument(
54
+ "-v", "--version",
55
+ action="version",
56
+ version=f"pycg3 {__version__} (compatible with VISL CG-3)",
57
+ help="Show version information and exit."
58
+ )
59
+
60
+ parsed = parser.parse_args(args)
61
+
62
+ # Read grammar
63
+ try:
64
+ with open(parsed.grammar, "r", encoding="utf-8", errors="replace") as gf:
65
+ grammar_text = gf.read()
66
+ except Exception as e:
67
+ sys.stderr.write(f"Error opening grammar file '{parsed.grammar}': {e}\n")
68
+ sys.exit(1)
69
+
70
+ grammar = GrammarParser().parse(grammar_text)
71
+ engine = Disambiguator(grammar, trace=parsed.trace)
72
+
73
+ # Input stream
74
+ if parsed.input_file:
75
+ try:
76
+ in_stream = open(parsed.input_file, "r", encoding="utf-8", errors="replace")
77
+ except Exception as e:
78
+ sys.stderr.write(f"Error opening input file '{parsed.input_file}': {e}\n")
79
+ sys.exit(1)
80
+ else:
81
+ in_stream = sys.stdin
82
+
83
+ # Output stream
84
+ if parsed.output_file:
85
+ try:
86
+ out_stream = open(parsed.output_file, "w", encoding="utf-8")
87
+ except Exception as e:
88
+ sys.stderr.write(f"Error opening output file '{parsed.output_file}': {e}\n")
89
+ sys.exit(1)
90
+ else:
91
+ out_stream = sys.stdout
92
+
93
+ try:
94
+ for win in engine.run_stream(in_stream, num_sections=parsed.sections):
95
+ formatted = win.to_text(
96
+ show_rules=parsed.show_rules,
97
+ trace=parsed.trace,
98
+ pipe_deleted=parsed.pipe_deleted
99
+ )
100
+ out_stream.write(formatted)
101
+ out_stream.write("\n")
102
+ out_stream.flush()
103
+ finally:
104
+ if parsed.input_file:
105
+ in_stream.close()
106
+ if parsed.output_file:
107
+ out_stream.close()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
pycg3/cohort.py ADDED
@@ -0,0 +1,325 @@
1
+ """Cohort, Reading, and SingleWindow data structures."""
2
+
3
+ from typing import List, Set, Optional, Dict, Any
4
+ from pycg3.tag import is_wordform, is_baseform
5
+
6
+
7
+ class Reading:
8
+ """Represents a single reading of a cohort."""
9
+
10
+ __slots__ = (
11
+ 'baseform',
12
+ 'tags',
13
+ '_tag_set',
14
+ 'sub_readings',
15
+ 'deleted',
16
+ 'changed',
17
+ 'trace',
18
+ 'parent_cohort',
19
+ 'mapping_tags',
20
+ 'id',
21
+ 'parent_id',
22
+ 'rel_name',
23
+ '_match_cache',
24
+ 'mapped'
25
+ )
26
+
27
+ def __init__(self, baseform: str = "", tags: Optional[List[str]] = None, parent_cohort: Optional['Cohort'] = None):
28
+ self.baseform = baseform
29
+ self.tags: List[str] = list(tags) if tags is not None else []
30
+ self._tag_set: Set[str] = set(self.tags)
31
+ self.sub_readings: List['Reading'] = []
32
+ self.deleted = False
33
+ self.changed = False
34
+ self.trace: List[str] = []
35
+ self.parent_cohort: Optional['Cohort'] = parent_cohort
36
+ self.mapping_tags: Set[str] = set()
37
+ self.id: Optional[int] = None
38
+ self.parent_id: Optional[int] = None
39
+ self.rel_name: Optional[str] = None
40
+ self._match_cache: Optional[frozenset] = None
41
+ self.mapped = False
42
+
43
+ # Check for dependency / relation tags
44
+ for t in self.tags:
45
+ if t.startswith('#') and '->' in t:
46
+ # Dependency tag: e.g. #1->2 or #1->2:rel
47
+ parts = t[1:].split('->')
48
+ try:
49
+ self.id = int(parts[0])
50
+ if ':' in parts[1]:
51
+ pid, rname = parts[1].split(':', 1)
52
+ self.parent_id = int(pid)
53
+ self.rel_name = rname
54
+ else:
55
+ self.parent_id = int(parts[1])
56
+ except ValueError:
57
+ pass
58
+
59
+ def add_tag(self, tag: str, mapping_prefix: str = '@'):
60
+ """Add a tag to the reading if not already present."""
61
+ if tag not in self._tag_set:
62
+ self._match_cache = None
63
+ self.tags.append(tag)
64
+ self._tag_set.add(tag)
65
+ if tag.startswith(mapping_prefix):
66
+ self.mapping_tags.add(tag)
67
+ self.changed = True
68
+
69
+ def remove_tag(self, tag: str):
70
+ """Remove a tag from the reading."""
71
+ if tag in self._tag_set:
72
+ self._match_cache = None
73
+ self.tags = [t for t in self.tags if t != tag]
74
+ self._tag_set.discard(tag)
75
+ self.mapping_tags.discard(tag)
76
+ self.changed = True
77
+
78
+ def replace_tag(self, old_tag: str, new_tags: List[str], mapping_prefix: str = '@'):
79
+ """Replace old_tag with one or more new_tags in-place."""
80
+ if old_tag not in self._tag_set:
81
+ return
82
+ self._match_cache = None
83
+ new_list = []
84
+ replaced = False
85
+ for t in self.tags:
86
+ if t == old_tag and not replaced:
87
+ new_list.extend(new_tags)
88
+ replaced = True
89
+ elif t != old_tag:
90
+ new_list.append(t)
91
+ self.tags = new_list
92
+ self._tag_set = set(self.tags)
93
+ self.mapping_tags = {t for t in self.tags if t.startswith(mapping_prefix)}
94
+ self.changed = True
95
+
96
+ def identity(self):
97
+ """What makes two readings the same: base form and tag set (order does not matter), sub-readings in order."""
98
+ return (self.baseform, frozenset(self.tags), tuple(sub.identity() for sub in self.sub_readings))
99
+
100
+ def has_tag(self, tag: str) -> bool:
101
+ """Check if reading contains exact tag."""
102
+ return tag in self._tag_set or tag == self.baseform
103
+
104
+ def match_set(self) -> frozenset:
105
+ """Every tag a set can match on this reading, base form and word form included (cached)."""
106
+ if self._match_cache is None:
107
+ self._match_cache = frozenset(self.all_tags())
108
+ return self._match_cache
109
+
110
+ def all_tags(self) -> List[str]:
111
+ """Return list of all tags including baseform and wordform."""
112
+ res = []
113
+ if self.baseform:
114
+ res.append(self.baseform)
115
+ res.extend(self.tags)
116
+ if self.parent_cohort and self.parent_cohort.wordform:
117
+ res.append(self.parent_cohort.wordform)
118
+ if self.parent_cohort and self.parent_cohort.is_end:
119
+ res.append('<<<')
120
+ return res
121
+
122
+ def to_text(self, indent: str = "\t", show_rules: bool = False, trace: bool = False) -> str:
123
+ """Format reading as VISL CG-3 text line."""
124
+ parts = []
125
+ if self.baseform:
126
+ parts.append(self.baseform)
127
+ parts.extend(self.tags)
128
+ line = indent + " ".join(parts)
129
+ if show_rules and self.changed:
130
+ line += " <<<"
131
+ if trace and self.trace:
132
+ line += " " + " ".join(self.trace)
133
+ lines = [line]
134
+ for sub in self.sub_readings:
135
+ lines.append(sub.to_text(indent=indent + "\t", show_rules=show_rules, trace=trace))
136
+ return "\n".join(lines)
137
+
138
+ def clone(self, parent_cohort: Optional['Cohort'] = None) -> 'Reading':
139
+ """Deep copy reading for cohort operations."""
140
+ nr = Reading(self.baseform, list(self.tags), parent_cohort=parent_cohort)
141
+ nr.deleted = self.deleted
142
+ nr.changed = self.changed
143
+ nr.trace = list(self.trace)
144
+ nr.mapping_tags = set(self.mapping_tags)
145
+ nr.id = self.id
146
+ nr.parent_id = self.parent_id
147
+ nr.rel_name = self.rel_name
148
+ nr.mapped = self.mapped
149
+ nr.sub_readings = [s.clone(parent_cohort=parent_cohort) for s in self.sub_readings]
150
+ return nr
151
+
152
+ def __repr__(self) -> str:
153
+ return f"Reading({self.baseform} {' '.join(self.tags)})"
154
+
155
+
156
+ class Cohort:
157
+ """Represents a cohort (a wordform with its readings and untouchable tags)."""
158
+
159
+ __slots__ = (
160
+ 'wordform',
161
+ 'untouchable_tags',
162
+ '_untouchable_set',
163
+ 'readings',
164
+ 'deleted_readings',
165
+ 'text_before',
166
+ 'line_number',
167
+ 'global_number',
168
+ 'parent_window',
169
+ 'is_end',
170
+ '_wread'
171
+ )
172
+
173
+ def __init__(
174
+ self,
175
+ wordform: str = "",
176
+ untouchable_tags: Optional[List[str]] = None,
177
+ text_before: Optional[List[str]] = None,
178
+ line_number: int = 0,
179
+ global_number: int = 0,
180
+ parent_window: Optional['SingleWindow'] = None
181
+ ):
182
+ self.wordform = wordform
183
+ self.untouchable_tags: List[str] = list(untouchable_tags) if untouchable_tags is not None else []
184
+ self._untouchable_set: Set[str] = set(self.untouchable_tags)
185
+ self.readings: List[Reading] = []
186
+ self.deleted_readings: List[Reading] = []
187
+ self.text_before: List[str] = list(text_before) if text_before is not None else []
188
+ self.line_number = line_number
189
+ self.global_number = global_number
190
+ self.parent_window: Optional['SingleWindow'] = parent_window
191
+ self._wread = None
192
+ self.is_end = False # the last cohort of a window: its readings answer to the tag <<<
193
+
194
+ def append_reading(self, reading: Reading):
195
+ """Append a reading to this cohort."""
196
+ reading.parent_cohort = self
197
+ self.readings.append(reading)
198
+
199
+ def dedupe_readings(self, with_trace: bool = False) -> bool:
200
+ """Drop later readings that have the same base form and the same set of tags as an earlier one
201
+ (vislcg3 does this silently, on input and after a rule has changed a reading). With -t the rules
202
+ that hit a reading are part of what it is, so readings with different traces stay apart."""
203
+ seen, keep = set(), []
204
+ for r in self.readings:
205
+ key = (r.identity(), tuple(r.trace) if with_trace else ())
206
+ if key in seen and not r.deleted:
207
+ continue
208
+ seen.add(key)
209
+ keep.append(r)
210
+ changed = len(keep) != len(self.readings)
211
+ self.readings = keep
212
+ return changed
213
+
214
+ def active_readings(self) -> List[Reading]:
215
+ """Return active (non-deleted) readings."""
216
+ return [r for r in self.readings if not r.deleted]
217
+
218
+ def select_reading(self, reading: Reading, rule_name: str = ""):
219
+ """Select this reading, removing all other non-matching readings."""
220
+ for r in self.readings:
221
+ if r is not reading and not r.deleted:
222
+ r.deleted = True
223
+ r.changed = True
224
+ if rule_name:
225
+ r.trace.append(rule_name)
226
+ self.deleted_readings.append(r)
227
+ self.readings = [reading]
228
+ reading.changed = True
229
+ if rule_name:
230
+ reading.trace.append(rule_name)
231
+
232
+ def remove_reading(self, reading: Reading, rule_name: str = ""):
233
+ """Remove a reading."""
234
+ if not reading.deleted:
235
+ reading.deleted = True
236
+ reading.changed = True
237
+ if rule_name:
238
+ reading.trace.append(rule_name)
239
+ self.deleted_readings.append(reading)
240
+ self.readings = [r for r in self.readings if r is not reading]
241
+
242
+ def to_text(self, show_rules: bool = False, trace: bool = False, pipe_deleted: bool = False) -> str:
243
+ """Format cohort into VISL CG-3 stream format."""
244
+ out = []
245
+ if self.text_before:
246
+ out.extend(self.text_before)
247
+ header = self.wordform
248
+ if self.untouchable_tags:
249
+ header += " " + " ".join(self.untouchable_tags)
250
+ out.append(header)
251
+ for r in self.readings:
252
+ out.append(r.to_text(show_rules=show_rules, trace=trace))
253
+ if pipe_deleted:
254
+ for r in self.deleted_readings:
255
+ # vislcg3 prints a deleted reading, and each of its sub-reading lines, with a leading ';'
256
+ out.extend(";" + ln for ln in r.to_text(show_rules=show_rules, trace=trace).split("\n"))
257
+ return "\n".join(out)
258
+
259
+ def clone(self, parent_window: Optional['SingleWindow'] = None) -> 'Cohort':
260
+ """Deep copy cohort."""
261
+ nc = Cohort(
262
+ self.wordform,
263
+ list(self.untouchable_tags),
264
+ list(self.text_before),
265
+ self.line_number,
266
+ self.global_number,
267
+ parent_window=parent_window
268
+ )
269
+ for r in self.readings:
270
+ nc.append_reading(r.clone(parent_cohort=nc))
271
+ nc.deleted_readings = [r.clone(parent_cohort=nc) for r in self.deleted_readings]
272
+ return nc
273
+
274
+ def __repr__(self) -> str:
275
+ return f"Cohort({self.wordform}, {len(self.readings)} readings)"
276
+
277
+
278
+ class SingleWindow:
279
+ """Represents a window (sentence / clause) of cohorts."""
280
+
281
+ __slots__ = ('cohorts', 'text_after', 'variables', 'flush_after', 'begin')
282
+
283
+ def __init__(self, cohorts: Optional[List[Cohort]] = None):
284
+ self.cohorts: List[Cohort] = list(cohorts) if cohorts is not None else []
285
+ self.text_after: List[str] = []
286
+ self.variables: Dict[str, str] = {}
287
+ self.flush_after = False
288
+ # CG-3 puts a begin cohort (tag >>>) before every window that only contextual tests can reach
289
+ self.begin = self._edge('>>>')
290
+ for c in self.cohorts:
291
+ c.parent_window = self
292
+
293
+ def _edge(self, tag: str) -> Cohort:
294
+ c = Cohort(wordform="", parent_window=self)
295
+ c.append_reading(Reading(baseform="", tags=[tag]))
296
+ return c
297
+
298
+ def mark_end(self):
299
+ """The last cohort of the window gets the tag <<< (for matching only), as vislcg3 does."""
300
+ for c in self.cohorts:
301
+ if c.is_end:
302
+ c.is_end = False
303
+ for r in c.readings:
304
+ r._match_cache = None
305
+ if self.cohorts:
306
+ last = self.cohorts[-1]
307
+ last.is_end = True
308
+ for r in last.readings:
309
+ r._match_cache = None
310
+
311
+ def virtual(self, vi: int) -> Cohort:
312
+ """Cohort at virtual index vi: 0 is the begin cohort, 1..n the real cohorts."""
313
+ return self.begin if vi == 0 else self.cohorts[vi - 1]
314
+
315
+ def append_cohort(self, cohort: Cohort):
316
+ cohort.parent_window = self
317
+ self.cohorts.append(cohort)
318
+
319
+ def to_text(self, show_rules: bool = False, trace: bool = False, pipe_deleted: bool = False) -> str:
320
+ lines = []
321
+ for c in self.cohorts:
322
+ lines.append(c.to_text(show_rules=show_rules, trace=trace, pipe_deleted=pipe_deleted))
323
+ if self.text_after:
324
+ lines.extend(self.text_after)
325
+ return "\n".join(lines)