|
33 | 33 | import sys |
34 | 34 |
|
35 | 35 | # Either an explicit HumanName("...") call -- the reporter showing the |
36 | | -# failing input -- or a quoted, capitalized phrase, which is how names |
37 | | -# appear in prose ("parsing 'Hans "Hansi" Müller' gives ..."). |
| 36 | +# failing input -- or a capitalized phrase in quotes or BACKTICKS, |
| 37 | +# which is how names appear in prose ("parsing 'Hans "Hansi" Müller' |
| 38 | +# gives ...", or `Beethoven, Ludwig van` in a markdown issue). |
| 39 | +# |
| 40 | +# The backtick branch was missing until #413, and the cost was not |
| 41 | +# marginal: this tracker writes names in backticks because issues are |
| 42 | +# markdown, so the corpus whose whole purpose is "what users reported" |
| 43 | +# was blind to the way this project reports. Under the screens this |
| 44 | +# file ships the backtick pass adds 94 names that the quoted branch |
| 45 | +# never saw, none of them in the previous corpus, and they include the |
| 46 | +# headline example of the issue that fixed them -- `Beethoven, Ludwig |
| 47 | +# van` (#379), `Berg, Jan vd` (#380), `Ursula von der Leyen geb. |
| 48 | +# Albrecht` (#399). That last one sat in its issue's TITLE while #399 |
| 49 | +# shipped noting the class was invisible to this harness. |
38 | 50 | _CANDIDATE = re.compile( |
39 | 51 | r"""HumanName\(\s*["']([^"']{3,60})["']""" |
40 | 52 | r"""|["']([A-Z][^"'\n]{4,60})["']""" |
41 | 53 | ) |
| 54 | +# Backticks are scanned SEPARATELY, not as a third alternative, and the |
| 55 | +# reason is a name this cost before it was split out. A reporter writes |
| 56 | +# the failing input as `HumanName("John Q Doe")` -- a call inside a |
| 57 | +# code span. Regex scanning takes the leftmost match, so a backtick |
| 58 | +# alternative starts one character before the HumanName branch can and |
| 59 | +# swallows the whole span, harvesting the CALL and losing the name |
| 60 | +# inside it. Two passes over the same text cannot shadow each other. |
| 61 | +_BACKTICKED = re.compile(r"""`([A-Z][^`\n]{4,60})`""") |
| 62 | +# ... and the same code span is dropped from the backtick pass, or the |
| 63 | +# call itself is harvested as a name. The HumanName branch above has |
| 64 | +# already taken the argument out of it, which is the part anyone meant. |
| 65 | +# |
| 66 | +# Searched, not matched: a call is code wherever it sits in the span, |
| 67 | +# and anchoring at offset 0 let `Foo HumanName("x y")` through with the |
| 68 | +# call still attached. |
| 69 | +_A_CALL = re.compile(r"""[A-Za-z_]\w*\(""") |
42 | 70 | # Structural characters that mean we caught code or markup, not a name. |
43 | | -_NOT_A_NAME = set('{}<>=/\\|') |
| 71 | +# |
| 72 | +# Extended when the backtick branch landed (#413): backticks wrap code |
| 73 | +# far more often than quotes do, so the branch admits code-shaped |
| 74 | +# strings a quoted phrase rarely produces -- 'Constants.__init__(self, |
| 75 | +# **state)', 'DEVIATION #364', 'SUFFIX_ACRONYMS ∩ SUFFIX_NOT_ACRONYMS', |
| 76 | +# 'TypeError: a bytes-like object is required'. None of '*#~;_:' |
| 77 | +# appears in a name; the colon alone accounts for three error messages |
| 78 | +# and a PyPI trove classifier, and none of the six characters occurs in |
| 79 | +# any name across the four corpora. The one existing entry this drops, |
| 80 | +# 'St. ___', is a placeholder, not a name. |
| 81 | +_NOT_A_NAME = set('{}<>=/\\|*#~;_:') |
| 82 | + |
| 83 | +#: Words that make a string a SENTENCE rather than a name. The |
| 84 | +#: character screen above cannot reach prose that is merely prose -- |
| 85 | +#: 'What this gate does not cover', 'Takes everything', 'C is |
| 86 | +#: CONSTANTS' are all well-formed capitalized phrases. |
| 87 | +#: |
| 88 | +#: NOT a backtick problem, though the backtick branch is what prompted |
| 89 | +#: looking: five of the six sentences this drops come from the QUOTED |
| 90 | +#: branch, which has been running since the harvester was written. |
| 91 | +#: They are new to the corpus because the tracker grew, not because of |
| 92 | +#: the new pass -- re-harvesting with backticks disabled still adds 88 |
| 93 | +#: names over the previous snapshot. Scoping this screen to the |
| 94 | +#: backtick pass would look like a tightening and would silently |
| 95 | +#: readmit five of them. |
| 96 | +#: |
| 97 | +#: Matched only against a word written in LOWERCASE, which is what |
| 98 | +#: keeps the screen off names. A name capitalizes its words and a |
| 99 | +#: sentence's function words do not, so 'Chan On Kei', 'U Than Shwe', |
| 100 | +#: 'Sven Are Olsen', 'Kertu Must', 'Wafula Were' and 'John Does' all |
| 101 | +#: survive while 'NO Latin twins on purpose' does not. Without the |
| 102 | +#: case test this list silently drops real names, and the corpus it |
| 103 | +#: drops them from is the one whose whole purpose is catching what |
| 104 | +#: users report. |
| 105 | +#: |
| 106 | +#: The exclusions are the load-bearing part, and they divide into two |
| 107 | +#: kinds. 'and', 'the', 'of', 'do' and 'will' are excluded on corpus |
| 108 | +#: evidence -- 'Rob And Beth Edmunds', 'The Hon', 'Duke of Wellington', |
| 109 | +#: 'Anh Do' and 'Will De Groot' are all in the corpora. 'can' and 'no' |
| 110 | +#: are excluded on no corpus evidence at all: they are Turkish Can and |
| 111 | +#: Korean No, names this harvest has not reached yet. The distinction |
| 112 | +#: matters because the corpus cannot supply evidence for a word any |
| 113 | +#: screen already rejects, so absence from it is not a licence. A name is allowed to |
| 114 | +#: be odd -- 'der, y van' is real -- it is not allowed to be a |
| 115 | +#: sentence. |
| 116 | +_PROSE = frozenset({ |
| 117 | + "is", "are", "was", "were", "be", "been", "this", "that", "these", |
| 118 | + "those", "what", "which", "how", "why", "when", "not", "does", |
| 119 | + "did", "without", "everything", "takes", "reads", "sure", "about", |
| 120 | + "because", "than", "then", "into", "over", "under", "should", |
| 121 | + "would", "could", "must", "on", |
| 122 | +}) |
44 | 123 |
|
45 | 124 |
|
46 | 125 | def _harvest(text: str) -> set[str]: |
47 | 126 | found = set() |
48 | | - for match in _CANDIDATE.finditer(text): |
49 | | - value = (match.group(1) or match.group(2) or "").strip() |
| 127 | + values = [(m.group(1) or m.group(2) or "").strip() |
| 128 | + for m in _CANDIDATE.finditer(text)] |
| 129 | + values += [m.group(1).strip() for m in _BACKTICKED.finditer(text) |
| 130 | + if not _A_CALL.search(m.group(1))] |
| 131 | + for value in values: |
50 | 132 | # Require an internal space: a single word is a surname at best |
51 | 133 | # and carries no structure to disagree about. |
52 | | - if value and " " in value and not (_NOT_A_NAME & set(value)): |
53 | | - found.add(value) |
| 134 | + if not value or " " not in value or (_NOT_A_NAME & set(value)): |
| 135 | + continue |
| 136 | + if any(s and s == s.lower() and s in _PROSE |
| 137 | + for s in (w.strip(".,'\"") for w in value.split())): |
| 138 | + continue |
| 139 | + found.add(value) |
54 | 140 | return found |
55 | 141 |
|
56 | 142 |
|
|
0 commit comments