Our reading app serves Portuguese as Brazilian Portuguese, everywhere: você, arquivo, cadastro, café da manhã. The book generator that feeds the catalogue did not get that memo. Left to itself it wrote European Portuguese, and 29 public books had to be converted to Brazilian on the production database. The generator's original bundles were still lying around, so a single re-upload of an old file would have quietly put the European text back.
The fix is a gate in the importer: a pt book that contains European forms is refused. This post walks through how that check works, what it catches, and where a regex-based language-variant detector bites.
The shape of the check
The detector is a table of labelled regular expressions and a function that returns every hit with some context around it:
def european_portuguese(text): """[(label, snippet)] — the European Portuguese forms in `text`.""" out = [] for label, rx in PT_EUROPEAN: for m in re.finditer(rx, text or "", re.IGNORECASE | re.UNICODE): out.append((label, text[max(0, m.start() - 30):m.end() + 30].replace("\n", " "))) return out It returns snippets rather than a boolean because the person uploading a book needs to see where the problem is. The importer turns the first hit into the error message:
if language == "pt": european = langs.european_portuguese(title + "\n" + content) if european: label, snippet = european[0] raise BookImportError( f"European Portuguese in a pt book ({len(european)} places, first: " f"{label} — «{snippet.strip()}»); Portuguese here is Brazilian only") The table itself has six rows. The selection rule was empirical: a form goes in only if it appeared in the European versions of those 29 books and never in the Brazilian ones. That rule is why a sua casa is not in the table, even though textbooks list the article-before-possessive as European: Brazilian writers use it too.
Six kinds of evidence
1. Second-person tu / vós and their possessives. tu, vós, vosso, contigo, convosco, teu, tua… Brazilian prose addresses the reader with você.
2. ter de + infinitive. Europeans write tenho de ir, Brazilians tenho que ir. The pattern lists the conjugated forms of ter followed by de and something that ends like an infinitive (-ar, -er, -ir, -or, -ôr).
3. estar a + infinitive. The European progressive: está a comer where Brazil says está comendo. Same infinitive tail, with ficar, continuar and andar in the verb list too.
4. European vocabulary. A word list: autocarro, comboio, rapariga, pequeno-almoço, casa de banho, telemóvel, ecrã, equipa, facto, ficheiro, registo and 18 more entries, many with a \w* tail to catch inflections.
5. European spelling. One regex: \b\w*[óé][mn][aeiou]\w*. Before m or n followed by a vowel, Portugal writes an acute accent and Brazil a circumflex: económico / econômico, fenómeno / fenômeno, António / Antônio.
6. Guillemets. [«»]. European fiction quotes dialogue in « »; Brazilian prose uses dashes and curly quotes.
Running the repository's own test sentences through the detector gives this (real output, trimmed to the label and snippet):
EUROPEAN hits: tu/vós | Tu tens de ir já. Ela estava a c ter de | Tu tens de ir já. Ela estava a comer na cas estar a + infinitive | Tu tens de ir já. Ela estava a comer na casa de banho. «Que facto European word | ir já. Ela estava a comer na casa de banho. «Que facto económico!», diss European word | comer na casa de banho. «Que facto económico!», disse a rapariga European word | ue facto económico!», disse a rapariga ao pequeno-almoço. European word | nómico!», disse a rapariga ao pequeno-almoço. European spelling | na casa de banho. «Que facto económico!», disse a rapariga ao pequen « » quotes | ava a comer na casa de banho. «Que facto económico!», disse a « » quotes | e banho. «Que facto económico!», disse a rapariga ao pequeno- BRAZILIAN hits: [] Two sentences of European Portuguese produce ten hits across all six labels. The Brazilian control text, which deliberately includes A sua casa fica perto and a mulher dele, produces none.
The infinitive trap
Rows 2 and 3 look for "something that ends in -ar / -er / -ir / -or". Portuguese has plenty of nouns with exactly those endings: mulher, lugar, mar, olhar, jantar, prazer. So ela está a mulher mais alta would look like the European progressive.
The pattern guards against that with a negative lookahead in front of the infinitive:
_NOT_INFINITIVE = (r"(?!(?:mulher|colher|lugar|mar|ar|bar|altar|olhar|luar|lar|par|prazer|" r"poder|dever|açúcar|militar|familiar|particular|popular|jantar|talher)\b)") _INF = _NOT_INFINITIVE + r"\w*(?:ar|er|ir|ôr|or)\b" The \b inside the lookahead matters: without it, mar would also exclude marcar and ar would exclude every infinitive in the language. Some probes:
'Ela tem de comprar pão.' -> ['ter de'] 'A sala tem de lugar para dez.' -> [] 'Ela está a comer.' -> ['estar a + infinitive'] 'Ela está a mulher mais alta.' -> [] 'A parede tem de cor branca.' -> ['ter de'] The last line is a miss in the other direction: cor (colour) ends in -or and is not on the exclusion list. The sentence is clumsy Portuguese and never came up in the books, which is how a list like this grows: one real false positive at a time, not by trying to enumerate the lexicon up front.
The spelling trap
\b\w*[óé][mn][aeiou]\w* is the bluntest row. It leaves the common Brazilian words alone because their accented vowel sits at the end of the word, with no vowel after the nasal:
'Também, porém, alguém, ninguém.' -> [] 'Ele mora em Belém.' -> [] 'O fenómeno económico.' -> ['European spelling', 'European spelling'] 'O fenômeno econômico.' -> [] 'A Mónica e o António.' -> ['European spelling', 'European spelling'] 'A Mônica e o Antônio.' -> [] But it knows nothing about loanwords:
'O menino jogava Pokémon.' -> ['European spelling'] A Brazilian children's story that mentions Pokémon would be refused. The row is still worth its keep: the importer is a gate in front of a content pipeline, not a classifier facing arbitrary user text, so a false positive costs one human a second look, and the error message carries the snippet, so that look takes seconds. If loanwords start showing up, the fix is the same lookahead trick as above.
Why a table and not a classifier
The requirement is narrow and strict: one pipeline must never put a known set of forms back into the catalogue. A table built from the actual bad bundles explains every refusal by label and snippet and runs in microseconds. The generator holds the same table for its own gate, so both ends of the pipeline agree on what "European" means.
The tests
Three tests in test_pt_brazilian.py pin the behaviour:
def test_each_european_form_is_found_and_brazilian_text_is_clean(): labels = {label for label, _ in langs.european_portuguese(EUROPEAN)} assert labels == {label for label, _ in langs.PT_EUROPEAN} assert langs.european_portuguese(BRAZILIAN) == [] The first assertion compares against the table's own labels, so adding a row without extending the European sample sentence fails the test. The other two run the real importer in dry-run mode: a European bundle raises BookImportError matching "European Portuguese", while the Brazilian bundle imports and the same European text tagged as Spanish passes, because the check only runs for pt.
============================== 3 passed in 0.14s =============================== The books this gate protects are on the Portuguese shelf of graded readers, from A0 to C1, all in Brazilian Portuguese.
This article was drafted with AI assistance; we ran the code and checked every figure and claim before publishing.


