Skip to content

Commit

Permalink
feat: name corruptor (#10)
Browse files Browse the repository at this point in the history
* feat(names): name corruptor

* test(debug): added ipdb

* test(coverage): Back to 100%

* feat(faker): faker for fake names

* chore(pre-commit): upgrades
  • Loading branch information
kierun authored May 4, 2023
1 parent bc5fa85 commit 7909b79
Show file tree
Hide file tree
Showing 11 changed files with 672 additions and 358 deletions.
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ repos:
hooks:
- id: isort
- repo: https://github.com/myint/docformatter
rev: v1.6.0
rev: v1.6.5
hooks:
- id: docformatter
- repo: https://gitlab.com/smop/pre-commit-hooks
Expand All @@ -40,7 +40,7 @@ repos:
- id: python-bandit-vulnerability-check
args: [--verbose, --ini, .banditrc, --recursive, setupr]
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.260
rev: v0.0.264
hooks:
- id: ruff
- repo: https://github.com/abravalheri/validate-pyproject
Expand All @@ -60,7 +60,7 @@ repos:
# _______________
# ___/ Markdown lint \_________________________________________________________
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.33.0
rev: v0.34.0
hooks:
- id: markdownlint
args: [--ignore, CHANGELOG.md, -s, .mdl_style.rb]
Expand All @@ -73,7 +73,7 @@ repos:
# ________________
# ___/ Docker linters \________________________________________________________
- repo: https://github.com/IamTheFij/docker-pre-commit
rev: v2.1.1
rev: v3.0.1
hooks:
- id: docker-compose-check
# _______________
Expand Down
13 changes: 13 additions & 0 deletions docs/name-corruption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Name corruption

Did we use a formal implementation of
[Grimm's law](https://en.wikipedia.org/wiki/Grimm%27s_law)? No.

Did we use some other philological data? Also no.

Did we do some careful research and testing? Again, no.

We sat around with a bit of code and a file watcher and interactively updated
the rules, watching 50 names update. The list is what we came up with after
about 30 minutes interactive play, and updated all the names in the list by at
least one corruption.
697 changes: 366 additions & 331 deletions poetry.lock

Large diffs are not rendered by default.

19 changes: 13 additions & 6 deletions pynpc/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,19 @@ def main(

# Run commands.
logger.debug("Starting real work…")
# TODO: pass extra data dirs in somehow
_do_stuff(logger, output)

# We should be done…
logger.debug("That's all folks!")
wprint("Operation was successful.", level="success")
sys.exit(EXIT_CODE_SUCCESS)


def _do_stuff(logger: structlog.BoundLogger, output: str) -> None: # pragma: no cover
"""Do stuff.
This has no unit tests since it does everything. We could mock everything, but why?
"""
x = NPC()
if output.lower() == "console":
rprint(x)
Expand All @@ -240,11 +252,6 @@ def main(
else: # pragma: no cover
rprint("Report a bug.")

# We should be done…
logger.debug("That's all folks!")
wprint("Operation was successful.", level="success")
sys.exit(EXIT_CODE_SUCCESS)


def _version_check() -> None:
"""Check if we are running the latest verion from GitHub."""
Expand Down
32 changes: 32 additions & 0 deletions pynpc/data/name-corruption-pattern.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[
["pir", "per"],
["ie", "iey"],
["sa", "za", "tsa", "tzah"],
["th", "dd", "t"],
["gnu", "gnae"],
["cel", "ciel", "sel", "tzel"],
["lot", "lod"],
["ric", "rick", "rik", "rijk"],
["ph", "ff", "f", "v", "vh"],
["na", "ne"],
["er", "aer"],
["dwa", "dva", "tva", "cha"],
["ao", "ai", "aiwa", "awa", "a"],
["d", "t"],
["tta", "tva"],
["lle", "lla", "llya"],
["in", "en", "un", "um", "ium"],
["i", "ih", "y"],
["por", "pro"],
["b", "p", "f"],
["co", "ko", "kho"],
["an", "in", "ain"],
["zu", "tzu"],
["ace", "ache", "eiche"],
["tt", "t"],
["ys", "iz", "it", "itz", "its", "itsa", "itsah"],
["ia", "aya"],
["ena", "ina", "iyna"],
["era", "ira", "idra"],
["ick", "ich", "ech", "eckh"]
]
42 changes: 42 additions & 0 deletions pynpc/name_corruptor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""Name corruptor."""
from itertools import pairwise


def parse_patterns(sequences: list[list[str]]) -> list[str]:
"""Parse patterns."""
results = [] # type: ignore[var-annotated]
for seq in sequences:
results.extend(pairwise(seq))
return results


class NameCorruptor:
"""Name corruptor class."""

def __init__(self, patterns: list[str]) -> None:
"""Initialize."""
self.patterns = patterns
self.cursor = len(patterns)

def corrupt_once(self, name: str) -> str:
"""Corrupts a name."""
starting_cursor = self.cursor
cursor = (starting_cursor + 1) % len(self.patterns)
while cursor != starting_cursor:
# grab a pattern like ("d", "t")
(pattern, replacement) = self.patterns[cursor] # type: ignore[misc]

# replace it - eg "david".replace("d", "t") => "tavit"
new_name = name.replace(pattern, replacement) # type: ignore[has-type]
if new_name != name:
# if the name changed, we're done
self.cursor = cursor
# todo -- put 'relax' back in
return new_name

# if not, keep going with the next pattern
cursor = (cursor + 1) % len(self.patterns)

# if we get here, we didn't find any patterns that worked
return name
44 changes: 38 additions & 6 deletions pynpc/npc.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

import orjson
import structlog
from faker import Faker

from pynpc.name_corruptor import NameCorruptor, parse_patterns
from pynpc.skills import get_skill_value

rlog = structlog.get_logger("pynpc.npoc")
Expand Down Expand Up @@ -75,9 +77,30 @@ def __init__(self, what: str = "fantasy") -> None:
# so an extra dir can override core behaviour
self._resources[jobj["resource"]] = ResourceObject(source=jobj)

# Faker, for names.
self._fake = Faker(["en_GB"])
data = Path(Path(__file__).resolve().parent.parent, "pynpc", "data", "name-corruption-pattern.json")
patterns = parse_patterns(orjson.loads(data.read_text()))
self._corruptor = NameCorruptor(patterns)

# Generates the first one.
self.generate()

def _get_name(self, name: str, sz: int = 3) -> str:
"""Get corruptions variations from a name."""
try:
first, last = name.split(" ")
except ValueError as e:
rlog.error("Name is not in the expected format: 'first last'", name=name, error=e)
first = name
last = ""
generated = [first]
for _ in range(0, sz):
_next = generated[-1]
corrupted = self._corruptor.corrupt_once(_next)
generated.append(corrupted)
return f"{first} (" + " ".join(generated[1:]) + ") " + last

def reading(self) -> Reading:
"""Return either upwards or revesed tarot cards draw."""
card = self._resources["cards"].get_value()
Expand All @@ -87,7 +110,9 @@ def reading(self) -> Reading:

def generate(self) -> None:
"""Generate an NPC."""
self.name = "Random"
self.name_fem = self._get_name(self._fake.name_female()) # Female
self.name_mal = self._get_name(self._fake.name_male()) # Male
self.name_non = self._get_name(self._fake.name_nonbinary()) # Non-binary
_arc = self._resources["archetypes"].get_value()
self.nature = Trait(_arc["name"], _arc["description"])
self.demeanour = self.nature
Expand All @@ -108,11 +133,15 @@ def __repr__(self) -> str:
This is the simple console printing. Nothing fancy.
"""
skills = (
f"Name: {self.name}\n"
"\n"
"Names:\n"
f" ♀ {self.name_fem}\n"
f" ♂ {self.name_mal}\n"
f" ⚥ {self.name_non}\n"
f"Skills:\n"
f" Primary: {self.skill_primary.name}\n"
f" Secondary: {self.skill_secondary.name}\n"
f" Hobby: {self.skill_hobby.name}\n"
f" Primary: {self.skill_primary}\n"
f" Secondary: {self.skill_secondary}\n"
f" Hobby: {self.skill_hobby}\n"
f"Personality: {self.personality}\n"
f"Nature: {self.nature}\n"
f"Demeanor: {self.nature}\n"
Expand All @@ -128,7 +157,10 @@ def __repr__(self) -> str:
def to_markdown(self) -> str:
"""Print NPC in markdown."""
return f"""
# {self.name}
# Fame
- f"♀ Name: {self.name_fem}\n"
- f"♂ Name: {self.name_mal}\n"
- f"⚥ Name: {self.name_non}\n"
## Skills
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ structlog = "^22.1.0"
requests = "^2.28.2"
types-requests = "^2.28.11.17"
orjson = "^3.8.10"
faker = "^18.4.0"

[tool.poetry.group.dev.dependencies]
black = "^23.1.0"
Expand All @@ -44,6 +45,7 @@ dlint = "^0.14.0"
ipython = "^8.5.0"
pytest-sugar = "^0.9.6"
requests-mock = "^1.10.0"
ipdb = "^0.13.13"

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down Expand Up @@ -119,7 +121,7 @@ log_file_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(line
log_file_date_format = "%Y-%m-%d %H:%M:%S"

junit_duration_report = "total"
addopts = "-ra -q --junit-xml=pytest.xml --last-failed "
addopts = "-rA --junit-xml=pytest.xml --last-failed"

[tool.coverage.run]
parallel = true
Expand Down
4 changes: 3 additions & 1 deletion tests/test_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ def test_version():
(False, VersionCheck.LAGGING),
],
)
def test_pynpc_version_status(ask, check):
@patch("pynpc.console._do_stuff")
def test_pynpc_version_status(m_stuff, ask, check):
with patch("pynpc.console.check_if_latest_version") as mock_check, patch("pynpc.console.Confirm.ask") as mock_ask:
mock_ask.return_value = ask
m_stuff.return_value = None
mock_check.return_value = check
runner = CliRunner()
result = runner.invoke(main, ["--verbose"])
Expand Down
110 changes: 110 additions & 0 deletions tests/test_name_corruptor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""Name corruptor tests."""
from pathlib import Path

import pytest
from orjson import loads

from pynpc.name_corruptor import NameCorruptor, parse_patterns


def test_parse_single_sequence():
assert parse_patterns([["a", "b", "c"]]) == [("a", "b"), ("b", "c")]


def test_parse_multiple_sequences():
sut = [
["a", "b", "c"],
["d", "e", "f"],
]
expected = [
("a", "b"),
("b", "c"),
("d", "e"),
("e", "f"),
]
assert parse_patterns(sut) == expected


def test_corrupt_once():
corruptor = NameCorruptor(parse_patterns([["th", "ff"]]))
assert corruptor.corrupt_once("agatha") == "agaffa"


@pytest.mark.parametrize(
("name", "expected"),
[
("agatha", "agatha agadda agata agata agata"),
("aldwin", "aldwin altwin altwen altwun altwum"),
("althea", "althea alddea altea altea altea"),
("anselm", "anselm antzelm intzelm aintzelm aiwantzelm"),
("armin", "armin armen armun armum armium"),
(
"bartholomew",
"bartholomew barddolomew bartolomew partolomew fartolomew",
),
("berengar", "berengar baerengar baerungar baerumgar baeriumgar"),
("clarice", "clarice claricke clarike clarijke clarihjke"),
(
"constance",
"constance konstance khonstance khonstince khonstaince",
),
("dierk", "dierk dieyrk tieyrk tiheyrk tyeyrk"),
("eadric", "eadric eadrick eadrik eadrijk eatrijk"),
("edward", "edward edvard etvard echard echart"),
("eldrida", "eldrida eltrita eltrihta eltryta eltryta"),
("elfric", "elfric elfrick elfrik elfrijk elvrijk"),
("erna", "erna erne aerne aerne aerne"),
("eustace", "eustace eustache eusteiche eusteeche eusteeckhe"),
("felicity", "felicity velicity vhelicity vhelihcihty vhelycyty"),
("finnegan", "finnegan vinnegan vhinnegan vhennegan vhunnegan"),
("giselle", "giselle gitzelle gitzella gitzellya gihtzellya"),
("gerald", "gerald gaerald gaeralt gairalt gaidralt"),
("godric", "godric godrick godrik godrijk gotrijk"),
("gunther", "gunther gundder gunter guntaer gumtaer"),
("hadrian", "hadrian hatrian hatrihan hatryan hatryin"),
("heloise", "heloise heloihse heloyse heloize heloite"),
("isolde", "isolde isolte ihsolte ysolte izolte"),
("ivor", "ivor ivhor ihvhor yvhor yvhhor"),
("jocelyn", "jocelyn jocielyn joselyn jotzelyn jotzelyn"),
("lancelot", "lancelot lancielot lanselot lantzelot lantzelod"),
("lysandra", "lysandra lyzandra lytsandra lytzahndra lytzahntra"),
("magnus", "magnus magnaes magnees magnees magnees"),
(
"melisande",
"melisande melizande melitsande melitzahnde melitzahnte",
),
("merrick", "merrick merrickk merrikk merrijkk maerrijkk"),
("osborn", "osborn osporn osforn osvorn osvhorn"),
("philomena", "philomena ffilomena filomena vilomena vhilomena"),
("reginald", "reginald regineld reginelt regenelt regunelt"),
("rowena", "rowena rowene rowune rowume rowiume"),
("sabine", "sabine zabine tsabine tzahbine tzahbene"),
("seraphina", "seraphina seraffina serafina seravina seravhina"),
("sigfrid", "sigfrid sigvrid sigvhrid sigvhrit sihgvhriht"),
("tiberius", "tiberius tibaerius tihbaerihus tybaeryus typaeryus"),
("ulf", "ulf ulv ulvh ulvh ulvh"),
("urien", "urien urieyn uriheyn uryeyn uryeyn"),
("vespera", "vespera vhespera vhespaera vhesfaera vhesfaira"),
("wendel", "wendel wentel wuntel wumtel wiumtel"),
("wilfred", "wilfred wilvred wilvhred wilvhret wihlvhret"),
("winifred", "winifred winivred winivhred winivhret wenivhret"),
("xenia", "xenia xunia xumia xiumia xihumiha"),
("ysabel", "ysabel yzabel ytsabel ytzahbel ytzahpel"),
("zephyr", "zephyr zeffyr zefyr zevyr zevhyr"),
("zinnia", "zinnia zennia zunnia zumnia ziumnia"),
("zuriel", "zuriel zurieyl zuriheyl zuryeyl tzuryeyl"),
("zygmund", "zygmund zygmunt zygmumt zygmiumt zygmihumt"),
],
)
def test_corrupt_several(name, expected):
data = Path(Path(__file__).resolve().parent.parent, "pynpc", "data", "name-corruption-pattern.json")
patterns = parse_patterns(loads(data.read_text()))

corruptor = NameCorruptor(patterns)
generated = [name]
for _ in range(len(expected.split(" ")) - 1):
_next = generated[-1]
corrupted = corruptor.corrupt_once(_next)
generated.append(corrupted)
assert " ".join(generated) == expected
Loading

0 comments on commit 7909b79

Please sign in to comment.