From 8ac9eab7527be796e8ccf5f21c266dc5a41f6646 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 25 Jun 2026 17:58:48 -0400 Subject: [PATCH 1/6] fix(wordnet): align lex_filename with lexnames and capture adjective markers Two independent WordNet converter bugs surfaced while mapping WordNet into downstream consumers: - lex_filename was derived from a mis-ordered table: adv.all and adj.ppl were misplaced and every entry from index 2 onward was shifted by one, so e.g. lex_filenum=29 (verb.body) was labeled noun.time and lex_filenum=3 (noun.Tops) was labeled adv.all. Align LEX_FILE_NAMES with the canonical WordNet lexnames lexicographer-file table. - Adjective syntactic-position markers ((p)/(a)/(ip)) were left appended to the word token, which failed lemma validation and caused the entire synset to be silently dropped. Parse the marker off the word into the new Word.syntactic_marker field instead. Closes #9 Closes #8 --- src/glazing/wordnet/converter.py | 125 +++++++++++------- src/glazing/wordnet/models.py | 10 ++ tests/test_wordnet/test_converter.py | 182 ++++++++++++++++++++++++++- tests/test_wordnet/test_models.py | 14 +++ 4 files changed, 279 insertions(+), 52 deletions(-) diff --git a/src/glazing/wordnet/converter.py b/src/glazing/wordnet/converter.py index cdb498d..8a72c03 100644 --- a/src/glazing/wordnet/converter.py +++ b/src/glazing/wordnet/converter.py @@ -50,12 +50,17 @@ Word, ) from glazing.wordnet.types import ( + AdjPosition, LexFileName, PointerSymbol, VerbFrameNumber, WordNetPOS, ) +# Adjective syntactic-position markers appended to words in adjective data files, +# e.g. "galore(ip)" -> ("galore", "ip"). See wndb(5) / wninput(5). +ADJ_MARKER_RE = re.compile(r"^(?P.+)\((?Pip|a|p)\)$") + class WordNetConverter: """Parse WordNet database files into structured models. @@ -77,53 +82,57 @@ class WordNetConverter: Convert entire WordNet database to JSON Lines. """ - # Mapping from lexical file numbers to names + # Mapping from lexical file numbers to names. + # + # This follows the canonical WordNet ``lexnames`` lexicographer-file table + # (see wndb(5)). Note that ``adv.all`` is index 2 and ``adj.ppl`` is the + # final entry at index 44 -- the order is *not* a simple POS grouping. LEX_FILE_NAMES: ClassVar[dict[int, LexFileName]] = { 0: "adj.all", 1: "adj.pert", - 2: "adj.ppl", - 3: "adv.all", - 4: "noun.Tops", - 5: "noun.act", - 6: "noun.animal", - 7: "noun.artifact", - 8: "noun.attribute", - 9: "noun.body", - 10: "noun.cognition", - 11: "noun.communication", - 12: "noun.event", - 13: "noun.feeling", - 14: "noun.food", - 15: "noun.group", - 16: "noun.location", - 17: "noun.motive", - 18: "noun.object", - 19: "noun.person", - 20: "noun.phenomenon", - 21: "noun.plant", - 22: "noun.possession", - 23: "noun.process", - 24: "noun.quantity", - 25: "noun.relation", - 26: "noun.shape", - 27: "noun.state", - 28: "noun.substance", - 29: "noun.time", - 30: "verb.body", - 31: "verb.change", - 32: "verb.cognition", - 33: "verb.communication", - 34: "verb.competition", - 35: "verb.consumption", - 36: "verb.contact", - 37: "verb.creation", - 38: "verb.emotion", - 39: "verb.motion", - 40: "verb.perception", - 41: "verb.possession", - 42: "verb.social", - 43: "verb.stative", - 44: "verb.weather", + 2: "adv.all", + 3: "noun.Tops", + 4: "noun.act", + 5: "noun.animal", + 6: "noun.artifact", + 7: "noun.attribute", + 8: "noun.body", + 9: "noun.cognition", + 10: "noun.communication", + 11: "noun.event", + 12: "noun.feeling", + 13: "noun.food", + 14: "noun.group", + 15: "noun.location", + 16: "noun.motive", + 17: "noun.object", + 18: "noun.person", + 19: "noun.phenomenon", + 20: "noun.plant", + 21: "noun.possession", + 22: "noun.process", + 23: "noun.quantity", + 24: "noun.relation", + 25: "noun.shape", + 26: "noun.state", + 27: "noun.substance", + 28: "noun.time", + 29: "verb.body", + 30: "verb.change", + 31: "verb.cognition", + 32: "verb.communication", + 33: "verb.competition", + 34: "verb.consumption", + 35: "verb.contact", + 36: "verb.creation", + 37: "verb.emotion", + 38: "verb.motion", + 39: "verb.perception", + 40: "verb.possession", + 41: "verb.social", + 42: "verb.stative", + 43: "verb.weather", + 44: "adj.ppl", } def parse_data_file(self, filepath: Path | str, pos: WordNetPOS) -> list[Synset]: @@ -628,6 +637,30 @@ def convert_exceptions(self, wordnet_dir: Path | str, output_file: Path | str) - return len(all_entries) + @staticmethod + def _split_adj_marker(word: str) -> tuple[str, AdjPosition | None]: + """Split an adjective syntactic-position marker off a word token. + + In WordNet adjective data files the syntactic marker is appended, in + parentheses, directly onto the word with no intervening space -- e.g. + ``galore(ip)``, ``outback(a)``, ``ready_to_hand(p)``. See wndb(5). + + Parameters + ---------- + word : str + Raw word token from a data file (possibly marker-bearing). + + Returns + ------- + tuple[str, AdjPosition | None] + The lemma with the marker stripped, and the marker (``p``/``a``/ + ``ip``) if present, otherwise ``None``. + """ + match = ADJ_MARKER_RE.match(word) + if match is None: + return word, None + return match.group("lemma"), cast(AdjPosition, match.group("marker")) + def _parse_data_line(self, line: str) -> Synset | None: """Parse a line from WordNet data file. @@ -669,9 +702,9 @@ def _parse_data_line(self, line: str) -> Synset | None: for _ in range(w_cnt): if idx + 1 >= len(parts): break - lemma = parts[idx] + lemma, marker = self._split_adj_marker(parts[idx]) lex_id = int(parts[idx + 1], 16) - words.append(Word(lemma=lemma, lex_id=lex_id)) + words.append(Word(lemma=lemma, lex_id=lex_id, syntactic_marker=marker)) idx += 2 # Parse pointers diff --git a/src/glazing/wordnet/models.py b/src/glazing/wordnet/models.py index 61fba80..ccf861f 100644 --- a/src/glazing/wordnet/models.py +++ b/src/glazing/wordnet/models.py @@ -45,6 +45,7 @@ from glazing.base import GlazingBaseModel from glazing.types import LEMMA_PATTERN from glazing.wordnet.types import ( + AdjPosition, LexFileName, LexID, PointerSymbol, @@ -70,6 +71,10 @@ class Word(GlazingBaseModel): Frequency-based sense ordering from index.sense. tag_count : int, default=0 Semantic concordance tag count. + syntactic_marker : AdjPosition | None, default=None + Adjective syntactic-position marker from wndb(5): ``p`` (predicative), + ``a`` (attributive/prenominal), or ``ip`` (immediately postnominal). + Only adjectives carry a marker; ``None`` for all other parts of speech. Examples -------- @@ -78,12 +83,17 @@ class Word(GlazingBaseModel): 'dog' >>> word.lex_id 0 + >>> Word(lemma="galore", lex_id=0, syntactic_marker="ip").syntactic_marker + 'ip' """ lemma: str = Field(description="Word form (lowercase, underscores for spaces)") lex_id: LexID = Field(description="Lexical ID distinguishing same word in synset") sense_number: int | None = Field(default=None, description="Frequency-based sense ordering") tag_count: int = Field(default=0, ge=0, description="Semantic concordance tag count") + syntactic_marker: AdjPosition | None = Field( + default=None, description="Adjective syntactic-position marker (p/a/ip)" + ) @field_validator("lemma") @classmethod diff --git a/tests/test_wordnet/test_converter.py b/tests/test_wordnet/test_converter.py index f4bc050..a76c9f3 100644 --- a/tests/test_wordnet/test_converter.py +++ b/tests/test_wordnet/test_converter.py @@ -14,6 +14,7 @@ parse_index_file, parse_sense_index, ) +from glazing.wordnet.models import Synset class TestWordNetConverter: @@ -302,12 +303,92 @@ def test_is_valid_word_form(self, converter): assert not converter._is_valid_word_form("invalid@") def test_lex_file_names_mapping(self, converter): - """Test lexical file name mapping.""" - assert converter.LEX_FILE_NAMES[0] == "adj.all" - assert converter.LEX_FILE_NAMES[4] == "noun.Tops" - assert converter.LEX_FILE_NAMES[29] == "noun.time" - assert converter.LEX_FILE_NAMES[30] == "verb.body" - assert converter.LEX_FILE_NAMES[44] == "verb.weather" + """Test lexical file name mapping matches the canonical lexnames table. + + Regression test for the lex_filename/lex_filenum mismatch (issue #9): + adv.all is index 2 (not adj.ppl) and adj.ppl is the final entry at 44. + """ + # Canonical WordNet lexnames table (wndb(5)). + canonical = { + 0: "adj.all", + 1: "adj.pert", + 2: "adv.all", + 3: "noun.Tops", + 4: "noun.act", + 5: "noun.animal", + 6: "noun.artifact", + 7: "noun.attribute", + 8: "noun.body", + 9: "noun.cognition", + 10: "noun.communication", + 11: "noun.event", + 12: "noun.feeling", + 13: "noun.food", + 14: "noun.group", + 15: "noun.location", + 16: "noun.motive", + 17: "noun.object", + 18: "noun.person", + 19: "noun.phenomenon", + 20: "noun.plant", + 21: "noun.possession", + 22: "noun.process", + 23: "noun.quantity", + 24: "noun.relation", + 25: "noun.shape", + 26: "noun.state", + 27: "noun.substance", + 28: "noun.time", + 29: "verb.body", + 30: "verb.change", + 31: "verb.cognition", + 32: "verb.communication", + 33: "verb.competition", + 34: "verb.consumption", + 35: "verb.contact", + 36: "verb.creation", + 37: "verb.emotion", + 38: "verb.motion", + 39: "verb.perception", + 40: "verb.possession", + 41: "verb.social", + 42: "verb.stative", + 43: "verb.weather", + 44: "adj.ppl", + } + assert canonical == converter.LEX_FILE_NAMES + + # The POS prefix of each lex file name must agree with the synset type + # it can belong to. A noun lex_filenum must map to a noun.* name, etc. + assert converter.LEX_FILE_NAMES[3].startswith("noun.") + assert converter.LEX_FILE_NAMES[29].startswith("verb.") + assert converter.LEX_FILE_NAMES[2].startswith("adv.") + assert converter.LEX_FILE_NAMES[44].startswith("adj.") + + @pytest.mark.parametrize( + ("lex_filenum", "ss_type", "expected_name"), + [ + (3, "n", "noun.Tops"), # previously mislabeled "adv.all" + (28, "n", "noun.time"), + (29, "v", "verb.body"), # previously mislabeled "noun.time" + (43, "v", "verb.weather"), + (0, "a", "adj.all"), + (44, "a", "adj.ppl"), # previously mislabeled "verb.weather" + (2, "r", "adv.all"), # previously mislabeled "adj.ppl" + ], + ) + def test_parsed_synset_lex_filename_matches_filenum( + self, converter, lex_filenum, ss_type, expected_name + ): + """Parsed synsets derive lex_filename consistently from lex_filenum.""" + line = f"00001740 {lex_filenum:02d} {ss_type} 01 word 0 000 | a gloss" + synset = converter._parse_data_line(line) + assert synset is not None + assert synset.lex_filenum == lex_filenum + assert synset.lex_filename == expected_name + # The lex file name's POS prefix must be consistent with the synset type. + pos_prefix = {"n": "noun.", "v": "verb.", "r": "adv.", "a": "adj.", "s": "adj."} + assert synset.lex_filename.startswith(pos_prefix[ss_type]) def test_parse_data_file_empty_lines(self, converter, tmp_path): """Test data file parsing handles empty lines and comments.""" @@ -335,6 +416,95 @@ def test_parse_complex_gloss(self, converter, tmp_path): assert "nonliving" in synsets[0].gloss +class TestAdjectiveSyntacticMarkers: + """Test adjective syntactic-position marker parsing (issue #8).""" + + @pytest.fixture + def converter(self): + """Create WordNet converter instance.""" + return WordNetConverter() + + @pytest.mark.parametrize( + ("token", "expected"), + [ + ("galore(ip)", ("galore", "ip")), + ("outback(a)", ("outback", "a")), + ("ready_to_hand(p)", ("ready_to_hand", "p")), + ("out_of_reach(p)", ("out_of_reach", "p")), + ("dead-on(a)", ("dead-on", "a")), + # No marker: returned unchanged with None. + ("plain", ("plain", None)), + ("by-the-way", ("by-the-way", None)), + ("mother-in-law", ("mother-in-law", None)), + # Parenthetical that is not a valid marker must not be stripped. + ("foo(x)", ("foo(x)", None)), + ("note(2)", ("note(2)", None)), + ], + ) + def test_split_adj_marker(self, converter, token, expected): + """Markers are split off; non-marker tokens are left intact.""" + assert converter._split_adj_marker(token) == expected + + def test_marker_bearing_synset_is_retained(self, converter): + """A satellite adjective synset with a marker is no longer dropped. + + Before the fix the marker stayed on the lemma, failed lemma validation, + and the entire synset was silently discarded. + """ + line = ( + "00014377 00 s 02 abounding 0 galore(ip) 0 001 " + "& 00013906 a 0000 | existing in abundance" + ) + synset = converter._parse_data_line(line) + assert synset is not None + markers = {w.lemma: w.syntactic_marker for w in synset.words} + assert markers == {"abounding": None, "galore": "ip"} + + def test_all_marker_variants_in_data_file(self, converter, tmp_path): + """Parse a data.adj file exercising each marker type.""" + content = ( + "00020141 00 s 02 outback(a) 0 remote 0 000 | inaccessible\n" + "00019769 00 s 02 handy 0 ready_to_hand(p) 0 000 | easy to reach\n" + "00014377 00 s 02 abounding 0 galore(ip) 0 000 | in abundance\n" + "00001740 00 a 01 able 0 000 | having the means\n" + ) + data_file = tmp_path / "data.adj" + data_file.write_text(content, encoding="utf-8") + + synsets = converter.parse_data_file(data_file, "a") + converter.parse_data_file( + data_file, "s" + ) + # All four synsets survive parsing. + assert len(synsets) == 4 + + markers = {w.lemma: w.syntactic_marker for synset in synsets for w in synset.words} + assert markers["outback"] == "a" + assert markers["ready_to_hand"] == "p" + assert markers["galore"] == "ip" + # Words without a marker carry None. + assert markers["remote"] is None + assert markers["handy"] is None + assert markers["able"] is None + + def test_marker_survives_json_round_trip(self, converter): + """The syntactic_marker field round-trips through JSON serialization.""" + line = "00020141 00 s 01 outback(a) 0 000 | inaccessible" + synset = converter._parse_data_line(line) + assert synset is not None + + dumped = synset.model_dump_json() + restored = Synset.model_validate_json(dumped) + assert restored.words[0].lemma == "outback" + assert restored.words[0].syntactic_marker == "a" + + def test_non_adjective_words_have_no_marker(self, converter): + """Nouns and verbs never carry a syntactic marker.""" + line = "00001740 29 v 01 breathe 0 000 | take air" + synset = converter._parse_data_line(line) + assert synset is not None + assert synset.words[0].syntactic_marker is None + + class TestWordNetConverterFunctions: """Test WordNet converter module functions.""" diff --git a/tests/test_wordnet/test_models.py b/tests/test_wordnet/test_models.py index fe9998e..8b35d47 100644 --- a/tests/test_wordnet/test_models.py +++ b/tests/test_wordnet/test_models.py @@ -56,6 +56,20 @@ def test_word_lex_id_validation(self): with pytest.raises(ValidationError): Word(lemma="dog", lex_id=16) + def test_word_syntactic_marker(self): + """Test the adjective syntactic-position marker field (issue #8).""" + # Defaults to None for words without a marker. + assert Word(lemma="dog", lex_id=0).syntactic_marker is None + + # Accepts each valid AdjPosition value. + for marker in ("p", "a", "ip"): + word = Word(lemma="able", lex_id=0, syntactic_marker=marker) + assert word.syntactic_marker == marker + + # Rejects values outside the AdjPosition literal. + with pytest.raises(ValidationError): + Word(lemma="able", lex_id=0, syntactic_marker="x") + class TestPointer: """Test Pointer model.""" From d51886ba5d8346e77620c28bd35b33edacf9d5a5 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 25 Jun 2026 17:59:12 -0400 Subject: [PATCH 2/6] fix(framenet): populate FE Requires/Excludes constraints requiresFE/excludesFE are self-closing siblings of whose target FE name lives in the name attribute. The converter used find() to grab the first such element and then iterated its (nonexistent) child elements, so requires_fe and excludes_fe were always empty. Use findall() and read the name attribute from each sibling. Closes #7 --- src/glazing/framenet/converter.py | 24 +++--- tests/test_framenet/test_converter.py | 101 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/glazing/framenet/converter.py b/src/glazing/framenet/converter.py index a3054d5..2f3736b 100644 --- a/src/glazing/framenet/converter.py +++ b/src/glazing/framenet/converter.py @@ -274,20 +274,16 @@ def _parse_fe_constraints(self, fe_elem: etree._Element) -> tuple[list[str], lis tuple[list[str], list[str]] Requires and excludes lists. """ - requires_fe = [] - excludes_fe = [] - - requires_elem = fe_elem.find( - f"{{{self.namespace}}}requiresFE" if self.namespace else "requiresFE" - ) - if requires_elem is not None: - requires_fe = [req.get("name", "") for req in requires_elem] - - excludes_elem = fe_elem.find( - f"{{{self.namespace}}}excludesFE" if self.namespace else "excludesFE" - ) - if excludes_elem is not None: - excludes_fe = [exc.get("name", "") for exc in excludes_elem] + # FrameNet frame XML carries each constraint as a self-closing sibling + # of , e.g. . There can be + # several of each, and the target FE name lives in the ``name`` + # attribute, not in a child element. Collect every matching sibling. + requires_fe = [ + name for req in fe_elem.findall(self._tag("requiresFE")) if (name := req.get("name")) + ] + excludes_fe = [ + name for exc in fe_elem.findall(self._tag("excludesFE")) if (name := exc.get("name")) + ] return requires_fe, excludes_fe diff --git a/tests/test_framenet/test_converter.py b/tests/test_framenet/test_converter.py index c79079b..03bf428 100644 --- a/tests/test_framenet/test_converter.py +++ b/tests/test_framenet/test_converter.py @@ -336,3 +336,104 @@ def test_lu_index_missing_no_crash(self, tmp_path): assert data["id"] == 100 assert "lexical_units" in data assert len(data["lexical_units"]) == 0 + + +class TestFEConstraints: + """Test FE Requires/Excludes constraint parsing (issue #7).""" + + # Frame XML in the FrameNet default namespace, mirroring the real + # Personal_relationship frame: requiresFE/excludesFE are self-closing + # siblings of whose target name lives in the ``name`` attribute. + FRAME_XML = """ + + A frame about relationships. + + The first partner. + + + + + The second partner. + + + + + Both partners together. + + + + + When the relationship holds. + + """ + + @pytest.fixture + def frame_file(self, tmp_path): + """Write the constraint-bearing frame XML to a temp file.""" + path = tmp_path / "Personal_relationship.xml" + path.write_text(self.FRAME_XML, encoding="utf-8") + return path + + def test_requires_and_excludes_populated(self, frame_file): + """requiresFE/excludesFE names are captured from the ``name`` attribute.""" + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_file) + by_name = {fe.name: fe for fe in frame.frame_elements} + + assert by_name["Partner_1"].requires_fe == ["Partner_2"] + assert by_name["Partner_1"].excludes_fe == ["Partners"] + assert by_name["Partner_2"].requires_fe == ["Partner_1"] + assert by_name["Partner_2"].excludes_fe == ["Partners"] + + def test_multiple_excludes_on_one_fe(self, frame_file): + """An FE with several siblings keeps all of them.""" + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_file) + partners = next(fe for fe in frame.frame_elements if fe.name == "Partners") + + assert partners.excludes_fe == ["Partner_1", "Partner_2"] + assert partners.requires_fe == [] + + def test_fe_without_constraints_is_empty(self, frame_file): + """An FE with no constraint elements yields empty lists, not None.""" + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_file) + time_fe = next(fe for fe in frame.frame_elements if fe.name == "Time") + + assert time_fe.requires_fe == [] + assert time_fe.excludes_fe == [] + + def test_constraints_survive_json_round_trip(self, tmp_path, frame_file): + """Constraints are present in the serialized JSONL output.""" + converter = FrameNetConverter() + output_file = tmp_path / "frames.jsonl" + converter.convert_frames_directory(frame_file.parent, output_file) + + with output_file.open("r", encoding="utf-8") as f: + data = json.loads(f.readline()) + + by_name = {fe["name"]: fe for fe in data["frame_elements"]} + assert by_name["Partner_1"]["requires_fe"] == ["Partner_2"] + assert by_name["Partner_1"]["excludes_fe"] == ["Partners"] + assert by_name["Partners"]["excludes_fe"] == ["Partner_1", "Partner_2"] + + def test_real_personal_relationship_frame(self): + """Cross-check against the real FN 1.7 frame file if available.""" + frame_path = Path("framenet_v17/frame/Personal_relationship.xml") + if not frame_path.exists(): + pytest.skip("Real FrameNet data not available") + + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_path) + by_name = {fe.name: fe for fe in frame.frame_elements} + + # At least one FE must carry a non-empty constraint list. + assert any(fe.requires_fe for fe in frame.frame_elements) + assert any(fe.excludes_fe for fe in frame.frame_elements) + assert by_name["Partner_1"].requires_fe == ["Partner_2"] + assert "Partners" in by_name["Partner_1"].excludes_fe From 8f29591c311991779f701269ee4ca423ef5cd407 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 25 Jun 2026 17:59:24 -0400 Subject: [PATCH 3/6] ci(release): harden PyPI trusted-publishing release workflow Add a twine check gate, link the pypi environment to the project URL, and create a matching GitHub Release (which also gives Zenodo a release to archive) after a successful publish. Publishing still uses OIDC trusted publishing gated by the pypi environment; no API tokens are stored. --- .github/workflows/publish.yml | 46 ++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9851ad1..29cc878 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,14 @@ -name: Publish to PyPI +name: Release + +# Publishes a release to PyPI using OIDC trusted publishing (no API tokens) +# and creates a matching GitHub Release. Triggered by pushing a version tag, +# e.g. `git tag v0.2.3 && git push origin v0.2.3`. +# +# Required one-time configuration (repository settings): +# - PyPI trusted publisher: project `glazing`, owner `factslab`, +# repository `glazing`, workflow `publish.yml`, environment `pypi`. +# - GitHub environment `pypi` (optionally with required reviewers / tag +# protection rules) that gates the publish job. on: push: @@ -23,22 +33,27 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - pip install build + pip install build twine - name: Build distribution run: python -m build + - name: Check distribution + run: twine check dist/* + - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ - publish: + publish-pypi: name: Publish to PyPI needs: build runs-on: ubuntu-latest - environment: pypi + environment: + name: pypi + url: https://pypi.org/p/glazing permissions: id-token: write steps: @@ -50,3 +65,26 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: GitHub Release + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "${{ github.ref_name }}" + --repo "${{ github.repository }}" + --title "${{ github.ref_name }}" + --generate-notes + dist/* From 7daabcd1cba3a04f7deb411bd84a727e8b05b7be Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 25 Jun 2026 17:59:30 -0400 Subject: [PATCH 4/6] chore(release): bump version to 0.2.3 Patch release covering the WordNet and FrameNet converter fixes (#7, #8, #9). The new Word.syntactic_marker field is an additive, backward-compatible optional field. --- CHANGELOG.md | 12 ++++++++++++ docs/api/index.md | 2 +- docs/citation.md | 8 ++++---- docs/index.md | 2 +- pyproject.toml | 2 +- src/glazing/__version__.py | 2 +- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e3a33..0bb6772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.3] - 2026-06-25 + +### Added + +- **WordNet adjective syntactic-position markers** (`p` predicative, `a` attributive, `ip` immediately postnominal) are now parsed off adjective words into the new `Word.syntactic_marker` field (#8) + +### Fixed + +- **WordNet synset `lex_filename`** now derives from the canonical WordNet `lexnames` lexicographer-file table; previously every entry from index 2 onward was shifted, so e.g. `lex_filenum=29` (`verb.body`) was mislabeled `noun.time` and `lex_filenum=3` (`noun.Tops`) was mislabeled `adv.all` (#9) +- **WordNet adjective synsets carrying a syntactic marker** are no longer silently dropped during conversion; the parenthesized marker previously stayed on the lemma, failed lemma validation, and discarded the entire synset (#8) +- **FrameNet FE Requires/Excludes constraints** now populate `FrameElement.requires_fe` and `FrameElement.excludes_fe`; the converter previously read the (nonexistent) child elements of the first ``/`` tag rather than the `name` attribute of each sibling, so the lists were always empty (#7) + ## [0.2.2] - 2026-02-06 ### Added diff --git a/docs/api/index.md b/docs/api/index.md index b42cbcd..dc0a2e8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -118,7 +118,7 @@ except ValidationError as e: ## Version Compatibility -This documentation covers Glazing version 0.2.2. Check your installed version: +This documentation covers glazing version 0.2.3. Check your installed version: ```python import glazing diff --git a/docs/citation.md b/docs/citation.md index c05d7a1..ffc78f0 100644 --- a/docs/citation.md +++ b/docs/citation.md @@ -12,22 +12,22 @@ If you use Glazing in your research, please cite our work. title = {Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies}, year = {2025}, url = {https://github.com/factslab/glazing}, - version = {0.2.2}, + version = {0.2.3}, doi = {10.5281/zenodo.17467082} } ``` ### APA -White, A. S. (2025). *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies* (Version 0.2.2) [Computer software]. https://github.com/factslab/glazing +White, A. S. (2025). *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies* (Version 0.2.3) [Computer software]. https://github.com/factslab/glazing ### Chicago -White, Aaron Steven. 2025. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.2. https://github.com/factslab/glazing. +White, Aaron Steven. 2025. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.3. https://github.com/factslab/glazing. ### MLA -White, Aaron Steven. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.2, 2025, https://github.com/factslab/glazing. +White, Aaron Steven. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.3, 2025, https://github.com/factslab/glazing. ## Citing Datasets diff --git a/docs/index.md b/docs/index.md index f4a55f9..1b3b235 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,7 +93,7 @@ If you use Glazing in your research, please cite: title = {Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies}, year = {2025}, url = {https://github.com/factslab/glazing}, - version = {0.2.2}, + version = {0.2.3}, doi = {10.5281/zenodo.17467082} } ``` diff --git a/pyproject.toml b/pyproject.toml index d8411ff..c8e034b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "glazing" -version = "0.2.2" +version = "0.2.3" description = "Unified data models and interfaces for syntactic and semantic frame ontologies" readme = "README.md" requires-python = ">=3.13" diff --git a/src/glazing/__version__.py b/src/glazing/__version__.py index 051b025..6d75b81 100644 --- a/src/glazing/__version__.py +++ b/src/glazing/__version__.py @@ -1,4 +1,4 @@ """Version information for the glazing package.""" -__version__ = "0.2.2" +__version__ = "0.2.3" __version_info__ = tuple(int(i) for i in __version__.split(".")) From 736387ed7d150105b94ba3a51da35232f02c4f0b Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 25 Jun 2026 18:04:20 -0400 Subject: [PATCH 5/6] chore(downloader): rework dataset lookup to satisfy mypy 2.x strict mypy 2.x narrows dataset_lower to DatasetType after the membership guard, so the subsequent cast became a redundant-cast error under --strict. Fold the cast into the lookup on the still-unnarrowed str and use dict.get, which keeps the cast genuinely needed (so it passes mypy 1.x and 2.x) without changing behavior: unsupported names still raise ValueError. --- src/glazing/downloader.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/glazing/downloader.py b/src/glazing/downloader.py index f5f97d8..d3a8aa5 100644 --- a/src/glazing/downloader.py +++ b/src/glazing/downloader.py @@ -617,17 +617,15 @@ def get_downloader(dataset: DatasetType | str) -> BaseDownloader: >>> print(downloader.version) 3.4 """ - # Normalize to lowercase for case-insensitive lookup - dataset_lower = dataset.lower() - - if dataset_lower not in _DOWNLOADERS: + # Normalize to lowercase for case-insensitive lookup. The cast narrows the + # plain ``str`` to the key type for the lookup; ``.get`` still returns None + # for any unsupported name, so the cast never asserts a false membership. + dataset_lower = cast(DatasetType, dataset.lower()) + downloader_class = _DOWNLOADERS.get(dataset_lower) + if downloader_class is None: supported = ", ".join(_DOWNLOADERS.keys()) msg = f"Unsupported dataset: {dataset}. Supported: {supported}" raise ValueError(msg) - - # Cast to DatasetType for type checking - dataset_typed = cast(DatasetType, dataset_lower) - downloader_class = _DOWNLOADERS[dataset_typed] return downloader_class() From dd910b47238c7d9cd81d270529b7c50f45bde735 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 25 Jun 2026 19:18:01 -0400 Subject: [PATCH 6/6] build(deps): pin mypy to 2.1.0 to stop CI/local type-check drift The unpinned `mypy>=1.8.0` dev spec let CI resolve a newer mypy (2.x) than local environments, whose stricter `--strict` analysis failed Type Check on code that passed locally. Pin the exact version so CI and local runs agree; bump deliberately. --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c8e034b..3870dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,10 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.1.9", - "mypy>=1.8.0", + # Pinned: --strict behavior changes between minor releases, so an unpinned + # spec lets CI and local environments drift (and CI's Type Check fail on + # code that passes locally). Bump deliberately. + "mypy==2.1.0", "pytest>=7.4.3", "pytest-cov>=4.1.0", "pytest-asyncio>=0.21.1",