From 25b1b7f9f9b9d6e96c78a2ba1d4869d96fb50e3e Mon Sep 17 00:00:00 2001 From: Andrew Miner Date: Mon, 13 Oct 2025 20:59:15 -0600 Subject: [PATCH] Refactor writing/formatting systems --- .../{write/markdown => format}/__init__.py | 0 mcpacker/format/report/__init__.py | 0 .../markdown => format/report}/biomereport.py | 13 +- .../report}/biomereport_test.py | 27 +- mcpacker/format/report/composer.py | 11 + .../report/composer_test.py} | 2 +- .../report}/mineralreport.py | 13 +- .../report}/mineralreport_test.py | 30 +-- .../report}/mobspawnreport.py | 13 +- .../report}/mobspawnreport_test.py | 59 ++-- mcpacker/format/textcomposer.py | 63 +++++ mcpacker/format/textcomposer_test.py | 31 +++ mcpacker/format/tokenizer.py | 254 ++++++++++++++++++ mcpacker/format/tokenizer_test.py | 116 ++++++++ mcpacker/format/toml/__init__.py | 12 + mcpacker/format/toml/__init___test.py | 7 + mcpacker/format/toml/composer.py | 112 ++++++++ mcpacker/format/toml/composer_test.py | 60 +++++ mcpacker/format/toml/model.py | 130 +++++++++ mcpacker/format/toml/model_test.py | 7 + mcpacker/format/toml/parser.py | 206 ++++++++++++++ mcpacker/format/toml/parser_test.py | 45 ++++ mcpacker/model/blockfactory.py | 2 +- mcpacker/model/core/altitude.py | 11 +- mcpacker/model/core/ecology/biomefilter.py | 6 +- mcpacker/model/core/fauna/group.py | 6 +- mcpacker/model/core/fauna/mobspawn_test.py | 9 +- mcpacker/model/core/habitat.py | 31 ++- mcpacker/model/datapack/__init__.py | 5 + mcpacker/model/datapack/recipe/__init__.py | 20 ++ .../model/datapack/recipe/shapedrecipe.py | 56 ++++ .../datapack/recipe/shapedrecipe_test.py | 27 ++ .../model/datapack/recipe/shapelessrecipe.py | 28 ++ .../datapack/recipe/shapelessrecipe_test.py | 17 ++ mcpacker/model/modpack.py | 18 +- mcpacker/ui/runner.py | 27 +- mcpacker/ui/runner_test.py | 44 +-- mcpacker/write/compositewriter.py | 25 +- mcpacker/write/markdown/markdownwriter.py | 71 ----- .../write/markdown/markdownwriter_test.py | 43 --- mcpacker/write/markdown/report.py | 72 ----- mcpacker/write/markdown/report_test.py | 27 -- mcpacker/write/markdown/reportwriter.py | 25 -- mcpacker/write/report/__init__.py | 0 mcpacker/write/report/writer.py | 31 +++ mcpacker/write/textwriter.py | 26 ++ mcpacker/write/textwriter_test.py | 37 +++ mcpacker/write/writer.py | 5 +- 48 files changed, 1463 insertions(+), 417 deletions(-) rename mcpacker/{write/markdown => format}/__init__.py (100%) create mode 100644 mcpacker/format/report/__init__.py rename mcpacker/{write/markdown => format/report}/biomereport.py (53%) rename mcpacker/{write/markdown => format/report}/biomereport_test.py (74%) create mode 100644 mcpacker/format/report/composer.py rename mcpacker/{write/markdown/reportwriter_test.py => format/report/composer_test.py} (75%) rename mcpacker/{write/markdown => format/report}/mineralreport.py (54%) rename mcpacker/{write/markdown => format/report}/mineralreport_test.py (66%) rename mcpacker/{write/markdown => format/report}/mobspawnreport.py (82%) rename mcpacker/{write/markdown => format/report}/mobspawnreport_test.py (59%) create mode 100644 mcpacker/format/textcomposer.py create mode 100644 mcpacker/format/textcomposer_test.py create mode 100644 mcpacker/format/tokenizer.py create mode 100644 mcpacker/format/tokenizer_test.py create mode 100644 mcpacker/format/toml/__init__.py create mode 100644 mcpacker/format/toml/__init___test.py create mode 100644 mcpacker/format/toml/composer.py create mode 100644 mcpacker/format/toml/composer_test.py create mode 100644 mcpacker/format/toml/model.py create mode 100644 mcpacker/format/toml/model_test.py create mode 100644 mcpacker/format/toml/parser.py create mode 100644 mcpacker/format/toml/parser_test.py create mode 100644 mcpacker/model/datapack/recipe/__init__.py create mode 100644 mcpacker/model/datapack/recipe/shapedrecipe.py create mode 100644 mcpacker/model/datapack/recipe/shapedrecipe_test.py create mode 100644 mcpacker/model/datapack/recipe/shapelessrecipe.py create mode 100644 mcpacker/model/datapack/recipe/shapelessrecipe_test.py delete mode 100644 mcpacker/write/markdown/markdownwriter.py delete mode 100644 mcpacker/write/markdown/markdownwriter_test.py delete mode 100644 mcpacker/write/markdown/report.py delete mode 100644 mcpacker/write/markdown/report_test.py delete mode 100644 mcpacker/write/markdown/reportwriter.py create mode 100644 mcpacker/write/report/__init__.py create mode 100644 mcpacker/write/report/writer.py create mode 100644 mcpacker/write/textwriter.py create mode 100644 mcpacker/write/textwriter_test.py diff --git a/mcpacker/write/markdown/__init__.py b/mcpacker/format/__init__.py similarity index 100% rename from mcpacker/write/markdown/__init__.py rename to mcpacker/format/__init__.py diff --git a/mcpacker/format/report/__init__.py b/mcpacker/format/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcpacker/write/markdown/biomereport.py b/mcpacker/format/report/biomereport.py similarity index 53% rename from mcpacker/write/markdown/biomereport.py rename to mcpacker/format/report/biomereport.py index db33972..13c152e 100644 --- a/mcpacker/write/markdown/biomereport.py +++ b/mcpacker/format/report/biomereport.py @@ -1,16 +1,13 @@ -from mcpacker.write.markdown.markdownwriter import MarkdownWriter -from mcpacker.model.modpack import ModPack -from pathlib import Path +from mcpacker.model.modpack import ModPack +from mcpacker.format.report.composer import ReportComposer +from pathlib import Path # Class ############################################################################################ -class BiomeReport(MarkdownWriter): +class BiomeReport(ReportComposer): - def __init__(self, pack:ModPack, outputDir:Path): - super().__init__("biomes.md", pack, outputDir) - - def compose(self): + def doCompose(self): for biome in self.pack.world.biomes: self.line(f"# Biome: {biome.gameId} ({biome.city})") self.line() diff --git a/mcpacker/write/markdown/biomereport_test.py b/mcpacker/format/report/biomereport_test.py similarity index 74% rename from mcpacker/write/markdown/biomereport_test.py rename to mcpacker/format/report/biomereport_test.py index 3de921f..500e5c5 100644 --- a/mcpacker/write/markdown/biomereport_test.py +++ b/mcpacker/format/report/biomereport_test.py @@ -1,7 +1,9 @@ -from mcpacker.write.markdown.biomereport import BiomeReport -from mcpacker.model.core.ecology.biome import Biome -from mcpacker.model.modpack import ModPack -from pytest import fixture +from mcpacker.format.report.biomereport import BiomeReport +from mcpacker.model.core.ecology.biome import Biome +from mcpacker.model.modpack import AugmentFunc +from mcpacker.model.modpack import ModPack +from pytest import fixture +from typing import Callable import mcpacker.model.core.ecology.flora as F import mcpacker.model.core.ecology.geology as G @@ -32,22 +34,20 @@ def defineAddBiomes(): return addBiomes @fixture(name="pack") -def createPack(addBiomes): +def createPack(addBiomes:AugmentFunc): pack = ModPack("testModPack") pack.augment(addBiomes) yield pack @fixture(name="report") -def createReport(pack, tmp_path): - report = BiomeReport(pack, tmp_path) - report.write() - yield report +def createReport(pack:ModPack): + yield BiomeReport(pack).compose() + # Tests ############################################################################################ -def test_report(tmp_path, report): - path = tmp_path / "testModPack" / "reports" / "biomes.md" - assert path.read_text() == textwrap.dedent(""" +def test_report(report): + assert str(report).strip() == textwrap.dedent(""" # Biome: minecraft:plains (kansascity) * Flora: field @@ -65,5 +65,4 @@ def test_report(tmp_path, report): * Humidity: dry * Soil: sandy * Water: inland - - """).lstrip() + """).strip() diff --git a/mcpacker/format/report/composer.py b/mcpacker/format/report/composer.py new file mode 100644 index 0000000..c31c8b5 --- /dev/null +++ b/mcpacker/format/report/composer.py @@ -0,0 +1,11 @@ +from mcpacker.format.textcomposer import TextComposer +from mcpacker.model.modpack import ModPack + + +# Class ############################################################################################ + +class ReportComposer(TextComposer): + + def __init__(self, pack:ModPack, indentText:str=" "): + super().__init__(indentText) + self.pack = pack diff --git a/mcpacker/write/markdown/reportwriter_test.py b/mcpacker/format/report/composer_test.py similarity index 75% rename from mcpacker/write/markdown/reportwriter_test.py rename to mcpacker/format/report/composer_test.py index b00aa9d..1cba33e 100644 --- a/mcpacker/write/markdown/reportwriter_test.py +++ b/mcpacker/format/report/composer_test.py @@ -1,4 +1,4 @@ -import mcpacker.write.markdown.reportwriter +import mcpacker.format.report.composer # Tests ############################################################################################ diff --git a/mcpacker/write/markdown/mineralreport.py b/mcpacker/format/report/mineralreport.py similarity index 54% rename from mcpacker/write/markdown/mineralreport.py rename to mcpacker/format/report/mineralreport.py index e32eb1f..c6aaf4d 100644 --- a/mcpacker/write/markdown/mineralreport.py +++ b/mcpacker/format/report/mineralreport.py @@ -1,16 +1,13 @@ -from mcpacker.write.markdown.markdownwriter import MarkdownWriter -from mcpacker.model.modpack import ModPack -from pathlib import Path +from mcpacker.format.report.composer import ReportComposer +from mcpacker.model.modpack import ModPack +from pathlib import Path # Class ############################################################################################ -class MineralReport(MarkdownWriter): +class MineralReport(ReportComposer): - def __init__(self, pack:ModPack, outputDir:Path): - super().__init__("minerals.md", pack, outputDir) - - def compose(self): + def doCompose(self): for mineral in self.pack.world.minerals: self.line(f"# Mineral: {mineral.name}") self.line() diff --git a/mcpacker/write/markdown/mineralreport_test.py b/mcpacker/format/report/mineralreport_test.py similarity index 66% rename from mcpacker/write/markdown/mineralreport_test.py rename to mcpacker/format/report/mineralreport_test.py index 920d392..0e9f6ff 100644 --- a/mcpacker/write/markdown/mineralreport_test.py +++ b/mcpacker/format/report/mineralreport_test.py @@ -1,8 +1,9 @@ -from mcpacker.write.markdown.mineralreport import MineralReport -from mcpacker.model.core.geology.mineral import Mineral +from mcpacker.format.report.mineralreport import MineralReport +from mcpacker.model.core.geology.mineral import Mineral from mcpacker.model.core.geology.replacement import Replacement -from mcpacker.model.modpack import ModPack -from pytest import fixture +from mcpacker.model.modpack import AugmentFunc +from mcpacker.model.modpack import ModPack +from pytest import fixture import textwrap @@ -25,24 +26,18 @@ def defineAddMinerals(): return addMinerals @fixture(name="pack") -def createPack(addMinerals): - pack = ModPack("testModPack") - pack.augment(addMinerals) - yield pack +def createPack(addMinerals:AugmentFunc): + yield ModPack("testModPack").augment(addMinerals) @fixture(name="report") -def createReport(pack, tmp_path): - report = MineralReport(pack, tmp_path) - report.write() - yield report +def createReport(pack:ModPack): + yield MineralReport(pack).compose() # Tests ############################################################################################ -def test_report(tmp_path, report): - path = tmp_path / "testModPack" / "reports" / "minerals.md" - - assert path.read_text() == textwrap.dedent(""" +def test_report(report:MineralReport): + assert str(report).strip() == textwrap.dedent(""" # Mineral: copper * #minecraft:stone_replaceables => minecraft:copper_ore @@ -52,5 +47,4 @@ def test_report(tmp_path, report): * #minecraft:stone_replaceables => minecraft:iron_ore * #minecraft:deepslate_replaceables => minecraft:deepslate_iron_ore - - """).lstrip() + """).strip() diff --git a/mcpacker/write/markdown/mobspawnreport.py b/mcpacker/format/report/mobspawnreport.py similarity index 82% rename from mcpacker/write/markdown/mobspawnreport.py rename to mcpacker/format/report/mobspawnreport.py index 73685ca..cf4d679 100644 --- a/mcpacker/write/markdown/mobspawnreport.py +++ b/mcpacker/format/report/mobspawnreport.py @@ -1,17 +1,14 @@ +from mcpacker.format.report.composer import ReportComposer from mcpacker.model.core.ecology.biometrait import BiomeTrait -from mcpacker.model.modpack import ModPack -from mcpacker.write.markdown.markdownwriter import MarkdownWriter -from pathlib import Path +from mcpacker.model.modpack import ModPack +from pathlib import Path # Class ############################################################################################ -class MobSpawnReport(MarkdownWriter): +class MobSpawnReport(ReportComposer): - def __init__(self, pack:ModPack, outputDir:Path): - super().__init__("mobspawns.md", pack, outputDir) - - def compose(self): + def doCompose(self): for spawn in self.pack.world.mobSpawns: self.line(f"# Mob: {spawn.gameId}") self.line() diff --git a/mcpacker/write/markdown/mobspawnreport_test.py b/mcpacker/format/report/mobspawnreport_test.py similarity index 59% rename from mcpacker/write/markdown/mobspawnreport_test.py rename to mcpacker/format/report/mobspawnreport_test.py index bfd0468..f43d3e7 100644 --- a/mcpacker/write/markdown/mobspawnreport_test.py +++ b/mcpacker/format/report/mobspawnreport_test.py @@ -1,25 +1,26 @@ +from mcpacker.format.report.mobspawnreport import MobSpawnReport from mcpacker.model.core.ecology.biomefilter import BiomeFilter as BF -from mcpacker.model.core.fauna.mob import Mob -from mcpacker.model.core.fauna.mobspawn import MobSpawn -from mcpacker.model.core.geology.mineral import Mineral +from mcpacker.model.core.fauna.mob import Mob +from mcpacker.model.core.fauna.mobspawn import MobSpawn +from mcpacker.model.core.geology.mineral import Mineral from mcpacker.model.core.geology.replacement import Replacement -from mcpacker.model.core.habitat import Habitat -from mcpacker.model.modpack import ModPack -from mcpacker.write.markdown.mobspawnreport import MobSpawnReport -from pytest import fixture +from mcpacker.model.core.habitat import Habitat +from mcpacker.model.modpack import AugmentFunc +from mcpacker.model.modpack import ModPack +from pytest import fixture -import mcpacker.model.core.altitude as AL -import mcpacker.model.core.ecology.flora as FL -import mcpacker.model.core.ecology.geology as GE -import mcpacker.model.core.ecology.heat as HE +import mcpacker.model.core.altitude as AL +import mcpacker.model.core.ecology.flora as FL +import mcpacker.model.core.ecology.geology as GE +import mcpacker.model.core.ecology.heat as HE import mcpacker.model.core.ecology.humidity as HU -import mcpacker.model.core.ecology.soil as SO -import mcpacker.model.core.ecology.water as WA -import mcpacker.model.core.fauna.active as AC -import mcpacker.model.core.fauna.group as GR -import mcpacker.model.core.fauna.location as LO -import mcpacker.model.core.scarcity as SC -import mcpacker.model.core.season as SE +import mcpacker.model.core.ecology.soil as SO +import mcpacker.model.core.ecology.water as WA +import mcpacker.model.core.fauna.active as AC +import mcpacker.model.core.fauna.group as GR +import mcpacker.model.core.fauna.location as LO +import mcpacker.model.core.scarcity as SC +import mcpacker.model.core.season as SE import textwrap @@ -56,23 +57,18 @@ def defineAddMobSpawnss(): yield addMobSpawns @fixture(name="pack") -def createPack(addMobs, addMobSpawns): - pack = ModPack("testModPack") - pack.augment(addMobs) - pack.augment(addMobSpawns) - yield pack +def createPack(addMobs:AugmentFunc, addMobSpawns:AugmentFunc): + yield ModPack("testModPack").augment(addMobs).augment(addMobSpawns) @fixture(name="report") -def createReport(pack, tmp_path): - report = MobSpawnReport(pack, tmp_path) - report.write() - yield report +def createReport(pack:ModPack): + yield MobSpawnReport(pack).compose() + # Tests ############################################################################################ -def test_write(report, tmp_path): - path = tmp_path / "testModPack" / "reports" / "mobspawns.md" - assert path.read_text() == textwrap.dedent(""" +def test_write(report:MobSpawnReport): + assert str(report).strip() == textwrap.dedent(""" # Mob: minecraft:chicken * habitat 1 @@ -95,5 +91,4 @@ def test_write(report, tmp_path): * group: troup * location: outside * scarcity: uncommon - - """).lstrip() + """).strip() diff --git a/mcpacker/format/textcomposer.py b/mcpacker/format/textcomposer.py new file mode 100644 index 0000000..98d566f --- /dev/null +++ b/mcpacker/format/textcomposer.py @@ -0,0 +1,63 @@ +from typing import Any +from typing import Self + + +# Class ############################################################################################ + +class TextComposer: + + def __init__(self, indentText:str=" "): + self._indent = 0 + self._indentText = indentText + self._lineBuffer:list[str] = [] + self._lines:list[str] = [] + + def __str__(self) -> str: + return "\n".join(self._lines) + + def compose(self) -> Self: + self.doCompose() + return self + + # Helper Methods ########################################################### + + def doCompose(self): + raise NotImplementedError() + + def indent(self) -> Self: + self._indent += 1 + self._lineBuffer.insert(0, self._indentText) + return self + + def line(self, text:Any="") -> Self: + self.text(text) + + self._lines.append("".join(self._lineBuffer)) + self._lineBuffer = [] + while len(self._lineBuffer) < self._indent: + self._lineBuffer.append(self._indentText) + + return self + + def outdent(self) -> Self: + self._indent = max(0, self._indent - 1) + + if self._lineBuffer: + if self._lineBuffer[0] == self._indentText: + del self._lineBuffer[0] + + return self + + def reset(self) -> Self: + self._lineBuffer = [] + self._lines = [] + self._indent = 0 + return self + + def resetIndent(self) -> Self: + self._indent = 0 + return self + + def text(self, text:Any) -> Self: + self._lineBuffer.append(str(text)) + return self diff --git a/mcpacker/format/textcomposer_test.py b/mcpacker/format/textcomposer_test.py new file mode 100644 index 0000000..266eee0 --- /dev/null +++ b/mcpacker/format/textcomposer_test.py @@ -0,0 +1,31 @@ +from mcpacker.format.textcomposer import TextComposer +from pytest import fixture + +import textwrap + + +# Helper Classes ################################################################################### + +class SampleComposer(TextComposer): + + def doCompose(self): + self.text("alpha").line(" bravo") + self.indent() + self.line("charlie") + self.outdent() + self.text("delta").line(" echo") + +# Fixtures ######################################################################################### + +@fixture(name="composer") +def composeText(): + yield SampleComposer().compose() + +# Tests ############################################################################################ + +def test_composer(composer:TextComposer): + assert str(composer) == textwrap.dedent(""" + alpha bravo + charlie + delta echo + """).strip() diff --git a/mcpacker/format/tokenizer.py b/mcpacker/format/tokenizer.py new file mode 100644 index 0000000..1c9554e --- /dev/null +++ b/mcpacker/format/tokenizer.py @@ -0,0 +1,254 @@ +from collections.abc import Generator +from collections.abc import Iterable +from typing import Any + + +# Helper Classes ################################################################################### + +class Token: + + def __init__(self, name:str, text:str, line:int): + self.name = name + self.line = line + self.text = text + + def __repr__(self) -> str: + return ( + f"Token{{" + + f"name: {self.name}, " + + f"text: \"{self.text}\", " + + f"line: {self.line}" + + "}}" + ) + +class TokenizationError(Exception): + + def __init__(self, message:str, line:int): + self.message = message + self.line = line + + def __str__(self) -> str: + return f"TokenizationError:{self.line}: {self.message}" + + +# Constants ######################################################################################## + +DIGIT_SET = "0123456789" +IDENTIFIER_SET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" +SIGN_SET = "-+" + + +# Class ############################################################################################ + +class Tokenizer: + + def __init__( + self, + text:str, + skipWhitespace:bool=True, + comment:str="#", + escape:str="\\", + quotes:str="'\"", + ): + self._comment = comment + self._current = 0 + self._line = 0 + self._escape = escape + self._quotes = quotes + self._returnedTokens:list[Token] = [] + self._skipWhitespace = skipWhitespace + self._text = text + + def all(self) -> Iterable[Token]: + result = [] + while self.peek().name != "eof": + result.append(self.next()) + + return result + + def isFinished(self) -> bool: + return self._current >= len(self._text) + + def next(self) -> Token: + while True: + if self._returnedTokens: + token = self._returnedTokens.pop() + return token + + if self.isFinished(): + token = Token("eof", "", self._line) + return token + + token = ( + # order matters here: some tokens are subsets of other tokens, and must appear + # later in the list to avoid mistakes (e.g., bool/indentifier, float/int) + self._scanLetterSequence("open-paren", "(") or + self._scanLetterSequence("close-paren", ")") or + self._scanLetterSequence("open-brace", "{") or + self._scanLetterSequence("close-brace", "}") or + self._scanLetterSequence("open-bracket", "[") or + self._scanLetterSequence("close-bracket", "]") or + self._scanLetterSequence("period", ".") or + self._scanLetterSequence("comma", ",") or + self._scanLetterSequence("equal", "=") or + self._scanLetterSequence("colon", ":") or + self._scanLetterSequence("bool", "false") or + self._scanLetterSequence("bool", "true") or + self._scanLetterSet("whitespace", " \t") or + self._scanLetterSet("newline", "\r\n") or + self._scanFloat() or + self._scanBase10Int() or + self._scanIdentifier() or + self._scanQuotedText() or + self._scanLineComment() or + self._scanOther() + ) + + if (token.name in ["whitespace", "newline"]) and self._skipWhitespace: + continue + + return token + + def peek(self) -> Token: + token = self.next() + self.reject(token) + + return token + + def reject(self, token:Token): + self._returnedTokens.append(token) + + # Private Methods ########################################################## + + def _currentChar(self) -> str: + if self._current >= len(self._text): return "" + return self._text[self._current] + + def _nextLetter(self) -> int: + if self._current < len(self._text): + if self._currentChar() == "\n": + self._line += 1 + self._current += 1 + + return self._current + + def _scanBase10Int(self) -> Token|None: + start = self._current + char = self._currentChar() + if char in SIGN_SET: + self._nextLetter() # skip sign + char = self._currentChar() + + if char not in DIGIT_SET: return None + + while self._currentChar() in DIGIT_SET: + self._nextLetter() + + return Token("int", self._text[start:self._current], self._line) + + def _scanFloat(self) -> Token|None: + start = self._current + char = self._currentChar() + if char in SIGN_SET: + self._nextLetter() # skip sign + char = self._currentChar() + + if char not in DIGIT_SET: return None + + while self._currentChar() in DIGIT_SET: + self._nextLetter() + + if self._currentChar() != ".": + return Token("float", self._text[start:self._current], self._line) + + self._nextLetter() # skip '.' + + while self._currentChar() in DIGIT_SET: + self._nextLetter() + + if self._currentChar() != "e": + return Token("float", self._text[start:self._current], self._line) + + self._nextLetter() # skip 'e' + if char in SIGN_SET: + self._nextLetter() # skip sign + char = self._currentChar() + + while self._currentChar() in DIGIT_SET: + self._nextLetter() + + return Token("float", self._text[start:self._current], self._line) + + def _scanIdentifier(self) -> Token|None: + if self._currentChar() not in IDENTIFIER_SET: return None + start = self._current + + while self._currentChar() in IDENTIFIER_SET: + self._nextLetter() + + return Token("identifier", self._text[start:self._current], self._line) + + def _scanLetterSequence(self, name:str, text:str) -> Token|None: + start = self._current + for char in text: + if self._currentChar() != char: + self._current = start + return None + + self._nextLetter() + + return Token(name, self._text[start:self._current], self._line) + + def _scanLetterSet(self, name:str, letters:str) -> Token|None: + if self._currentChar() not in letters: return None + + start = self._current + while self._currentChar() in letters: + self._nextLetter() + + return Token(name, self._text[start:self._current], self._line) + + def _scanLineComment(self) -> Token|None: + if self._currentChar() != self._comment: return None + self._nextLetter() # skip comment char + while self._currentChar() in " \t": + self._nextLetter() + + start = self._current + while self._currentChar() != "\n": + self._nextLetter() + + return Token("comment", self._text[start:self._current], self._line) + + def _scanOther(self) -> Token: + start = self._current + self._nextLetter() + return Token("other", self._text[start:self._current], self._line) + + def _scanQuotedText(self) -> Token|None: + startChar = self._currentChar() + if startChar not in self._quotes: return None + + self._nextLetter() # skip starting quote + start = self._current + + chars:list[str] = [] + while True: + char = self._currentChar() + if char == self._escape: + self._nextLetter() # skip escape char + chars.append(self._currentChar()) + self._nextLetter() # skip following char + char = self._currentChar() + + if char == "": + raise TokenizationError(f"Expected {startChar}, but found end of text", self._line) + + if char == startChar: + token = Token("quoted-text", "".join(chars), self._line) + self._nextLetter() # skip final quote + return token + + chars.append(char) + + self._nextLetter() diff --git a/mcpacker/format/tokenizer_test.py b/mcpacker/format/tokenizer_test.py new file mode 100644 index 0000000..59e28f9 --- /dev/null +++ b/mcpacker/format/tokenizer_test.py @@ -0,0 +1,116 @@ +from pytest import fixture +from mcpacker.format.tokenizer import Tokenizer + +import textwrap + +# Fixtures ######################################################################################### + +@fixture(name="basicJsonTokens") +def createBasicJson(): + yield Tokenizer(textwrap.dedent(""" + { + "alpha": { + "foxtrot": { + "bravo": "cha\\"rlie", + "delta": 12.123, + "echo": [ + 1, 2, 3 + ] + } + }, + "golf hotel": { + "juliette": false + } + } + """).strip()).all() + +@fixture(name="basicTomlTokens") +def createBasicToml(): + yield Tokenizer(textwrap.dedent(""" + [alpha.foxtrot] + bravo = "cha\\"rlie" + # comment + delta = 12.123 + echo = [ + 1, 2, 3 + ] + + ["golf hotel"] + india = { juliette = false } + """).strip()).all() + +# Tests ############################################################################################ + +def test_basicJson(basicJsonTokens): + assert list(str(t) for t in basicJsonTokens) == [ + 'Token{name: open-brace, text: "{", line: 0}}', + 'Token{name: quoted-text, text: "alpha", line: 1}}', + 'Token{name: colon, text: ":", line: 1}}', + 'Token{name: open-brace, text: "{", line: 1}}', + 'Token{name: quoted-text, text: "foxtrot", line: 2}}', + 'Token{name: colon, text: ":", line: 2}}', + 'Token{name: open-brace, text: "{", line: 2}}', + 'Token{name: quoted-text, text: "bravo", line: 3}}', + 'Token{name: colon, text: ":", line: 3}}', + 'Token{name: quoted-text, text: "cha"rlie", line: 3}}', + 'Token{name: comma, text: ",", line: 3}}', + 'Token{name: quoted-text, text: "delta", line: 4}}', + 'Token{name: colon, text: ":", line: 4}}', + 'Token{name: float, text: "12.123", line: 4}}', + 'Token{name: comma, text: ",", line: 4}}', + 'Token{name: quoted-text, text: "echo", line: 5}}', + 'Token{name: colon, text: ":", line: 5}}', + 'Token{name: open-bracket, text: "[", line: 5}}', + 'Token{name: float, text: "1", line: 6}}', + 'Token{name: comma, text: ",", line: 6}}', + 'Token{name: float, text: "2", line: 6}}', + 'Token{name: comma, text: ",", line: 6}}', + 'Token{name: float, text: "3", line: 6}}', + 'Token{name: close-bracket, text: "]", line: 7}}', + 'Token{name: close-brace, text: "}", line: 8}}', + 'Token{name: close-brace, text: "}", line: 9}}', + 'Token{name: comma, text: ",", line: 9}}', + 'Token{name: quoted-text, text: "golf hotel", line: 10}}', + 'Token{name: colon, text: ":", line: 10}}', + 'Token{name: open-brace, text: "{", line: 10}}', + 'Token{name: quoted-text, text: "juliette", line: 11}}', + 'Token{name: colon, text: ":", line: 11}}', + 'Token{name: bool, text: "false", line: 11}}', + 'Token{name: close-brace, text: "}", line: 12}}', + 'Token{name: close-brace, text: "}", line: 13}}', + ] + +def test_basicToml(basicTomlTokens): + assert list(str(t) for t in basicTomlTokens) == [ + 'Token{name: open-bracket, text: "[", line: 0}}', + 'Token{name: identifier, text: "alpha", line: 0}}', + 'Token{name: period, text: ".", line: 0}}', + 'Token{name: identifier, text: "foxtrot", line: 0}}', + 'Token{name: close-bracket, text: "]", line: 0}}', + 'Token{name: identifier, text: "bravo", line: 1}}', + 'Token{name: equal, text: "=", line: 1}}', + 'Token{name: quoted-text, text: "cha"rlie", line: 1}}', + 'Token{name: comment, text: "comment", line: 2}}', + 'Token{name: identifier, text: "delta", line: 3}}', + 'Token{name: equal, text: "=", line: 3}}', + 'Token{name: float, text: "12.123", line: 3}}', + 'Token{name: identifier, text: "echo", line: 4}}', + 'Token{name: equal, text: "=", line: 4}}', + 'Token{name: open-bracket, text: "[", line: 4}}', + 'Token{name: float, text: "1", line: 5}}', + 'Token{name: comma, text: ",", line: 5}}', + 'Token{name: float, text: "2", line: 5}}', + 'Token{name: comma, text: ",", line: 5}}', + 'Token{name: float, text: "3", line: 5}}', + 'Token{name: close-bracket, text: "]", line: 6}}', + 'Token{name: open-bracket, text: "[", line: 8}}', + 'Token{name: quoted-text, text: "golf hotel", line: 8}}', + 'Token{name: close-bracket, text: "]", line: 8}}', + 'Token{name: identifier, text: "india", line: 9}}', + 'Token{name: equal, text: "=", line: 9}}', + 'Token{name: open-brace, text: "{", line: 9}}', + 'Token{name: identifier, text: "juliette", line: 9}}', + 'Token{name: equal, text: "=", line: 9}}', + 'Token{name: bool, text: "false", line: 9}}', + 'Token{name: close-brace, text: "}", line: 9}}', + ] diff --git a/mcpacker/format/toml/__init__.py b/mcpacker/format/toml/__init__.py new file mode 100644 index 0000000..e7fa97d --- /dev/null +++ b/mcpacker/format/toml/__init__.py @@ -0,0 +1,12 @@ +from mcpacker.format.toml.model import TomlDocument +from mcpacker.format.toml.parser import TomlParser +from mcpacker.format.toml.composer import TomlComposer + + +# Class ############################################################################################ + +def dumps(document:TomlDocument) -> str: + return str(TomlComposer(document).compose()) + +def loads(text:str) -> TomlDocument: + return TomlParser(text).parse() diff --git a/mcpacker/format/toml/__init___test.py b/mcpacker/format/toml/__init___test.py new file mode 100644 index 0000000..a917289 --- /dev/null +++ b/mcpacker/format/toml/__init___test.py @@ -0,0 +1,7 @@ +import mcpacker.format.toml + + +# Tests ############################################################################################ + +def test_syntax(): + pass diff --git a/mcpacker/format/toml/composer.py b/mcpacker/format/toml/composer.py new file mode 100644 index 0000000..abb26a0 --- /dev/null +++ b/mcpacker/format/toml/composer.py @@ -0,0 +1,112 @@ +from mcpacker.format.textcomposer import TextComposer +from mcpacker.format.toml.model import TomlArray +from mcpacker.format.toml.model import TomlArrayEntry +from mcpacker.format.toml.model import TomlComment +from mcpacker.format.toml.model import TomlDocument +from mcpacker.format.toml.model import TomlDocumentEntry +from mcpacker.format.toml.model import TomlMap +from mcpacker.format.toml.model import TomlMapEntry +from mcpacker.format.toml.model import TomlPair +from mcpacker.format.toml.model import TomlScalar +from mcpacker.format.toml.model import TomlScalarValue +from mcpacker.format.toml.model import TomlTable +from mcpacker.format.toml.model import TomlTableEntry +from mcpacker.format.toml.model import TomlTablePath +from mcpacker.format.toml.model import TomlValue +from typing import get_args + + +# Class ############################################################################################ + +class TomlComposer(TextComposer): + + def __init__(self, document:TomlDocument): + super().__init__() + self._document = document + + def doCompose(self): + self.reset() + + for entry in self._document.entries: + if isinstance(entry, TomlComment): self._composeComment(entry) + if isinstance(entry, TomlPair): self._composePair(entry) + if isinstance(entry, TomlTable): self._composeTable(entry) + + # Private Methods ########################################################## + + def _composeComment(self, comment:TomlComment): + self.text("# ").line(comment.text) + + def _composeMap(self, tomlMap:TomlMap): + self.text("{") + self.indent() + for entry in tomlMap.entries: + if isinstance(entry, TomlComment): self._composeComment(entry) + if isinstance(entry, TomlPair): self._composePair(entry) + self.outdent() + self.text("}") + + def _composeArray(self, array:TomlArray): + self.line("[") + self.indent() + needsDelimiter = False + for element in array.entries: + if needsDelimiter: self.line(",") + needsDelimiter = True + + if isinstance(element, TomlComment): + self._composeComment(element) + else: + self._composeValue(element) + + self.line() + self.outdent() + self.text("]") + + def _composePair(self, pair:TomlPair): + self.text(pair.key).text(" = ") + if isinstance(pair.value, TomlScalar): self._composeScalar(pair.value) + if isinstance(pair.value, TomlArray): self._composeArray(pair.value) + if isinstance(pair.value, TomlMap): self._composeMap(pair.value) + + def _composeScalar(self, scalar:TomlScalar): + if isinstance(scalar.value, bool): + self.text("true" if scalar.value else "false") + elif isinstance(scalar.value, int): + self.text(f"{scalar.value:d}") + elif isinstance(scalar.value, float): + self.text(f"{scalar.value:0.3f}") + elif isinstance(scalar.value, str): + value = (scalar.value + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\t", "\\t") + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + self.text("\"").text(value).text("\"") + + def _composeTable(self, table:TomlTable): + self.resetIndent() + self.text("[") + needsDelimiter = False + for pathPart in table.path.parts: + if needsDelimiter: self.text(".") + needsDelimiter = True + self.text(pathPart) + self.line("]") + + for entry in table.entries: + if isinstance(entry, TomlComment): + self._composeComment(entry) + elif isinstance(entry, TomlPair): + self._composePair(entry) + self.line() + + if len(table.entries) > 0: + self.line() + + def _composeValue(self, value:TomlValue): + if isinstance(value, TomlScalar): self._composeScalar(value) + if isinstance(value, TomlArray): self._composeArray(value) + if isinstance(value, TomlMap): self._composeMap(value) diff --git a/mcpacker/format/toml/composer_test.py b/mcpacker/format/toml/composer_test.py new file mode 100644 index 0000000..d8da696 --- /dev/null +++ b/mcpacker/format/toml/composer_test.py @@ -0,0 +1,60 @@ +from mcpacker.format.toml.composer import TomlComposer +from mcpacker.format.toml.model import TomlArray +from mcpacker.format.toml.model import TomlArrayEntry +from mcpacker.format.toml.model import TomlComment +from mcpacker.format.toml.model import TomlDocument +from mcpacker.format.toml.model import TomlDocumentEntry +from mcpacker.format.toml.model import TomlMap +from mcpacker.format.toml.model import TomlMapEntry +from mcpacker.format.toml.model import TomlPair +from mcpacker.format.toml.model import TomlScalar +from mcpacker.format.toml.model import TomlScalarValue +from mcpacker.format.toml.model import TomlTable +from mcpacker.format.toml.model import TomlTableEntry +from mcpacker.format.toml.model import TomlTablePath +from mcpacker.format.toml.model import TomlValue +from pytest import fixture + +import textwrap + + +# Fixtures ######################################################################################### + +@fixture(name="composer") +def createComposer(): + yield TomlComposer(TomlDocument([ + TomlTable(TomlTablePath(['alpha', 'foxtrot']), [ + TomlPair('bravo', TomlScalar('cha\"rlie')), + TomlComment('comment'), + TomlPair('delta', TomlScalar(12.123)), + TomlPair('echo', TomlArray([ + TomlScalar(1.0), + TomlScalar(2.0), + TomlScalar(3.0) + ])) + ]), + TomlTable(TomlTablePath(['golf hotel']), [ + TomlPair('india', TomlMap([ + TomlPair('juliette', TomlScalar(False)) + ])) + ]) + ])).compose() + + +# Tests ############################################################################################ + +def test_document(composer): + assert str(composer) == textwrap.dedent(""" + [alpha.foxtrot] + bravo = "cha\\"rlie" + # comment + delta = 12.123 + echo = [ + 1.000, + 2.000, + 3.000 + ] + + [golf hotel] + india = {juliette = false} + """).lstrip() diff --git a/mcpacker/format/toml/model.py b/mcpacker/format/toml/model.py new file mode 100644 index 0000000..971c17d --- /dev/null +++ b/mcpacker/format/toml/model.py @@ -0,0 +1,130 @@ +from __future__ import annotations +from collections.abc import Iterable +from typing import Union + + +# Type Support ##################################################################################### + +type TomlScalarValue = bool | int | float | str + +type TomlValue = TomlScalar | TomlArray | TomlMap + +type TomlArrayEntry = TomlComment | TomlValue + +type TomlMapEntry = TomlComment | TomlPair + +type TomlTableEntry = TomlComment | TomlPair + +type TomlDocumentEntry = TomlComment | TomlPair | TomlTable + + +# Classes ########################################################################################## + +class TomlArray: + + def __init__(self, entries:Iterable[TomlArrayEntry]): + self.entries = entries + + def __repr__(self) -> str: + return ( + "TomlArray{entries:[" + + ",".join(repr(e) for e in self.entries) + + "]}" + ) + +class TomlComment: + + def __init__(self, text:str): + self.text = text + + def __repr__(self) -> str: + return f"TomlComment{{text:{repr(self.text)}}}" + +class TomlDocument: + + def __init__(self, entries:Iterable[TomlDocumentEntry]|None=None): + self.entries = list(entries or []) + + def findTable(self, name:str) -> TomlTable|None: + for entry in self.entries: + if isinstance(entry, TomlTable): + if entry.name == name: + return entry + + return None + + def __repr__(self) -> str: + return ( + "TomlDocument{entries:[" + + ",".join(repr(e) for e in self.entries) + + "]}" + ) + +class TomlMap: + + def __init__(self, entries:Iterable[TomlMapEntry]): + self.entries = entries + + def __repr__(self) -> str: + return ( + "TomlMap{entries:[" + + ",".join(repr(e) for e in self.entries) + + "]}" + ) + +class TomlPair: + + def __init__(self, key:str, value:TomlValue): + self.key = key + self.value = value + + def __repr__(self) -> str: + return f"TomlPair{{key:{repr(self.key)},value:{repr(self.value)}}}" + +class TomlScalar: + + def __init__(self, value:TomlScalarValue): + self.value = value + + def __repr__(self) -> str: + return f"TomlScalar{{value:{repr(self.value)}}}" + +class TomlTable: + + def __init__( + self, + path:TomlTablePath, + entries:Iterable[TomlTableEntry]|None=None, + ): + self.path = path + self.entries = list(entries or []) + + @property + def name(self) -> str: + return str(self.path) + + def __repr__(self) -> str: + return ( + "TomlTable{" + + f"path:{repr(self.path)}," + + f"entries:[" + + ",".join(repr(e) for e in self.entries) + + "]" + + "}" + + ) + +class TomlTablePath: + + def __init__(self, parts:Iterable[str]): + self.parts = parts + + def __str__(self) -> str: + return ".".join(self.parts) + + def __repr__(self) -> str: + return ( + "TomlTablePath{parts:[" + + ".".join(repr(p) for p in self.parts) + + "]}" + ) diff --git a/mcpacker/format/toml/model_test.py b/mcpacker/format/toml/model_test.py new file mode 100644 index 0000000..0406616 --- /dev/null +++ b/mcpacker/format/toml/model_test.py @@ -0,0 +1,7 @@ +import mcpacker.format.toml.model + + +# Class ############################################################################################ + +def test_syntax(): + pass diff --git a/mcpacker/format/toml/parser.py b/mcpacker/format/toml/parser.py new file mode 100644 index 0000000..5215955 --- /dev/null +++ b/mcpacker/format/toml/parser.py @@ -0,0 +1,206 @@ +from mcpacker.format.tokenizer import Tokenizer +from mcpacker.format.toml.model import TomlArray +from mcpacker.format.toml.model import TomlArrayEntry +from mcpacker.format.toml.model import TomlComment +from mcpacker.format.toml.model import TomlDocument +from mcpacker.format.toml.model import TomlDocumentEntry +from mcpacker.format.toml.model import TomlMap +from mcpacker.format.toml.model import TomlMapEntry +from mcpacker.format.toml.model import TomlPair +from mcpacker.format.toml.model import TomlScalar +from mcpacker.format.toml.model import TomlScalarValue +from mcpacker.format.toml.model import TomlTable +from mcpacker.format.toml.model import TomlTableEntry +from mcpacker.format.toml.model import TomlTablePath +from mcpacker.format.toml.model import TomlValue + + +# Helper Classes ################################################################################### + +class TomlParseError(Exception): + + def __init__(self, message:str, line:int): + self.message = message + self.line = line + + def __str__(self) -> str: + return f"TomlParseError:{self.line}: {self.message}" + + +# Class ############################################################################################ + +class TomlParser: + + def __init__(self, text:str): + self._tokenizer = Tokenizer(text) + + def parse(self) -> TomlDocument: + entries = [] + while True: + entry = self._parseDocumentEntry() + if not entry: break + entries.append(entry) + + return TomlDocument(entries) + + # Private Methods ########################################################## + + def _parseArray(self) -> TomlArray|None: + openToken = self._tokenizer.next() + if openToken.name != "open-bracket": + self._tokenizer.reject(openToken) + return None + + entries = [] + while True: + entry = self._parseArrayEntry() + if not entry: break + entries.append(entry) + + commaToken = self._tokenizer.next() + if commaToken.name != "comma": + self._tokenizer.reject(commaToken) + break + + closeToken = self._tokenizer.next() + if closeToken.name != "close-bracket": + raise TomlParseError(f"expected ] but found {closeToken.text}", closeToken.line) + + return TomlArray(entries) + + def _parseArrayEntry(self) -> TomlArrayEntry|None: + return self._parseComment() or self._parseValue() + + def _parseComment(self) -> TomlComment|None: + token = self._tokenizer.next() + if token.name != "comment": + self._tokenizer.reject(token) + return None + + return TomlComment(token.text) + + def _parseDocumentEntry(self) -> TomlDocumentEntry|None: + return self._parseComment() or self._parseTable() or self._parsePair() + + def _parseMapEntry(self) -> TomlMapEntry|None: + return self._parseComment() or self._parsePair() + + def _parseMap(self) -> TomlMap|None: + openToken = self._tokenizer.next() + if openToken.name != "open-brace": + self._tokenizer.reject(openToken) + return None + + entries = [] + while True: + entry = self._parseMapEntry() + if not entry: break + entries.append(entry) + + commaToken = self._tokenizer.next() + if commaToken.name != "comma": + self._tokenizer.reject(commaToken) + break + + closeToken = self._tokenizer.next() + if closeToken.name != "close-brace": + raise TomlParseError(f"expected }} but found {closeToken.text}", closeToken.line) + + return TomlMap(entries) + + def _parsePair(self) -> TomlPair|None: + keyToken = self._tokenizer.next() + if keyToken.name not in ["identifier", "quoted-text"]: + self._tokenizer.reject(keyToken) + return None + + equalToken = self._tokenizer.next() + if equalToken.name != "equal": + self._tokenizer.reject(equalToken) + self._tokenizer.reject(keyToken) + return None + + value = self._parseValue() + if not value: + self._tokenizer.reject(equalToken) + self._tokenizer.reject(keyToken) + return None + + return TomlPair(keyToken.text, value) + + def _parseScalar(self) -> TomlScalar|None: + token = self._tokenizer.next() + result = None + + if token.name == "bool": + result = TomlScalar(True if token.text.lower() == "true" else False) + elif token.name == "int": + result = TomlScalar(int(token.text)) + elif token.name == "float": + result = TomlScalar(float(token.text)) + elif token.name == "quoted-text": + result = TomlScalar(token.text) + else: + self._tokenizer.reject(token) + + return result + + def _parseTable(self) -> TomlTable|None: + path = self._parseTablePath() + + entries:list[TomlTableEntry] = [] + while True: + entry = self._parseTableEntry() + if not entry: break + entries.append(entry) + + if not (path or entries): + return None + elif not path: + path = TomlTablePath([""]) + + return TomlTable(path, entries) + + def _parseTableEntry(self) -> TomlTableEntry|None: + return self._parseComment() or self._parsePair() + + def _parseTablePath(self) -> TomlTablePath|None: + openToken = self._tokenizer.next() + if openToken.name != "open-bracket": + self._tokenizer.reject(openToken) + return None + + quotedNameToken = self._tokenizer.next() + if quotedNameToken.name == "quoted-text": + closeToken = self._tokenizer.next() + if closeToken.name != "close-bracket": + raise TomlParseError(f"expected ], but found {closeToken.text}", closeToken.line) + + return TomlTablePath([quotedNameToken.text]) + else: + self._tokenizer.reject(quotedNameToken) + + nameParts:list[str] = [] + while True: + namePartToken = self._tokenizer.next() + if namePartToken.name != "identifier": + raise TomlParseError( + f"expected a name part, but found {namePartToken.text}", + namePartToken.line + ) + + nameParts.append(namePartToken.text) + + periodToken = self._tokenizer.next() + if periodToken.name != "period": + self._tokenizer.reject(periodToken) + break + + closeToken = self._tokenizer.next() + if closeToken.name != "close-bracket": + raise TomlParseError(f"expected ], but found {closeToken.text}", closeToken.line) + + return TomlTablePath(nameParts) + + def _parseValue(self) -> TomlValue|None: + return self._parseScalar() or self._parseArray() or self._parseMap() diff --git a/mcpacker/format/toml/parser_test.py b/mcpacker/format/toml/parser_test.py new file mode 100644 index 0000000..96d6404 --- /dev/null +++ b/mcpacker/format/toml/parser_test.py @@ -0,0 +1,45 @@ +from pytest import fixture +from mcpacker.format.toml.parser import TomlParser + +import textwrap + + +# Fixtures ######################################################################################### + +@fixture(name="basicTomlDocument") +def createBasicTomlDocument(): + return TomlParser(textwrap.dedent(""" + [alpha.foxtrot] + bravo = "cha\\"rlie" + # comment + delta = 12.123 + echo = [ + 1, 2, 3 + ] + + ["golf hotel"] + india = { juliette = false } + """).strip()).parse() + +# Tests ############################################################################################ + +def test_basicTomlDocument(basicTomlDocument): + assert repr(basicTomlDocument) == ( + "TomlDocument{entries:[" + + "TomlTable{path:TomlTablePath{parts:['alpha'.'foxtrot']},entries:[" + + "TomlPair{key:'bravo',value:TomlScalar{value:'cha\"rlie'}}," + + "TomlComment{text:'comment'}," + + "TomlPair{key:'delta',value:TomlScalar{value:12.123}}," + + "TomlPair{key:'echo',value:TomlArray{entries:[" + + "TomlScalar{value:1.0}," + + "TomlScalar{value:2.0}," + + "TomlScalar{value:3.0}" + + "]}}" + + "]}," + + "TomlTable{path:TomlTablePath{parts:['golf hotel']},entries:[" + + "TomlPair{key:'india',value:TomlMap{entries:[" + + "TomlPair{key:'juliette',value:TomlScalar{value:False}}" + + "]}}" + + "]}" + + "]}" + ) diff --git a/mcpacker/model/blockfactory.py b/mcpacker/model/blockfactory.py index 2f63a12..1a1633a 100644 --- a/mcpacker/model/blockfactory.py +++ b/mcpacker/model/blockfactory.py @@ -3,6 +3,7 @@ from mcpacker.model.resourcepack.variant import Variant from mcpacker.model.resourcepack.model import Model from mcpacker.model.modpack import ModPack from mcpacker.model.core.resourceid import ResourceId +from mcpacker.model.datapack.recipe.shapedrecipe import ShapedRecipe # Class ############################################################################################ @@ -23,4 +24,3 @@ class BlockFactory: resource.blockStates.append(BlockState(Variant(blockId))) data = self.pack.dataPack.get(gameId.mod) - diff --git a/mcpacker/model/core/altitude.py b/mcpacker/model/core/altitude.py index ce26fb9..9c0380a 100644 --- a/mcpacker/model/core/altitude.py +++ b/mcpacker/model/core/altitude.py @@ -1,3 +1,6 @@ +from collections.abc import Iterable + + # Class ############################################################################################ class Altitude: @@ -25,6 +28,7 @@ class Altitude: def __repr__(self) -> str: return f"{self.name} <{self.bottom} to {self.top}>" + # Constants ######################################################################################## # Aboveground @@ -61,17 +65,18 @@ OVERGROUND = (DUNES, LOWLANDS, UPLANDS, HILLS, ALPINE, CRAGS, PEAKS, SUMMIT, SK UNDERGROUND = (SOIL, SUBSTRATE, OVERBURDEN, CRUST, MANTLE, PLUTONIC) UNDERWATER = (SURFACE, SHALLOWS, DEEPS, ABYSS) + # Helper Functions ################################################################################# -def span(*altitudes): +def span(*altitudes:Altitude): if not altitudes: return ANYWHERE result = Altitude("", ANYWHERE.top, ANYWHERE.bottom) bottom = OVERGROUND[-1].top - bottomAltitude = None + bottomAltitude = OVERGROUND[-1] top = UNDERGROUND[-1].bottom - topAltitude = None + topAltitude = UNDERGROUND[-1] for altitude in altitudes: if altitude.bottom < bottom: diff --git a/mcpacker/model/core/ecology/biomefilter.py b/mcpacker/model/core/ecology/biomefilter.py index f8e2b62..22f6899 100644 --- a/mcpacker/model/core/ecology/biomefilter.py +++ b/mcpacker/model/core/ecology/biomefilter.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +from collections.abc import Sequence from mcpacker.model.core.ecology.biome import Biome from mcpacker.model.core.ecology.biometrait import BiomeTrait @@ -26,8 +26,8 @@ class BiomeFilter: def __init__( self, - required:Iterable[BiomeTrait|Iterable[BiomeTrait]]|None=None, - prohibited:Iterable[BiomeTrait]|None=None, + required:Sequence[BiomeTrait|Sequence[BiomeTrait]]|None=None, + prohibited:Sequence[BiomeTrait]|None=None, ): self.required = required or [] self.prohibited = prohibited or [] diff --git a/mcpacker/model/core/fauna/group.py b/mcpacker/model/core/fauna/group.py index 5001af3..d37d53a 100644 --- a/mcpacker/model/core/fauna/group.py +++ b/mcpacker/model/core/fauna/group.py @@ -39,14 +39,14 @@ ALL = [SOLO, PAIR, FAMILY, TROUP, HERD] # Helper Functions ################################################################################# -def merge(*groups): +def merge(*groups:Group): if not groups: return SOLO smallest = ALL[-1].largest - smallestGroup = None + smallestGroup = ALL[-1] largest = ALL[0].smallest - largestGroup = None + largestGroup = ALL[0] for group in groups: if group.smallest < smallest: diff --git a/mcpacker/model/core/fauna/mobspawn_test.py b/mcpacker/model/core/fauna/mobspawn_test.py index c50438b..c90913c 100644 --- a/mcpacker/model/core/fauna/mobspawn_test.py +++ b/mcpacker/model/core/fauna/mobspawn_test.py @@ -4,19 +4,20 @@ from mcpacker.model.core.fauna.mobspawn import MobSpawn from mcpacker.model.core.habitat import Habitat from pytest import fixture -import mcpacker.model.core.ecology.flora as F -import mcpacker.model.core.ecology.heat as E +import mcpacker.model.core.fauna.active as AC +import mcpacker.model.core.ecology.flora as FL +import mcpacker.model.core.ecology.heat as HE # Fixtures ######################################################################################### @fixture(name="cow") def createCow(): - yield Mob("cow", "minecraft:cow") + yield Mob("minecraft:cow", AC.DIURNAL) @fixture(name="fields") def createFieldsHabitat(): - yield Habitat(biomeFilter=BiomeFilter([F.FIELD, E.TEMPERATE])) + yield Habitat(biomeFilter=BiomeFilter([FL.FIELD, HE.TEMPERATE])) @fixture(name="spawn") def createMobSpawn(cow, fields): diff --git a/mcpacker/model/core/habitat.py b/mcpacker/model/core/habitat.py index 4dad7d7..f6da7dd 100644 --- a/mcpacker/model/core/habitat.py +++ b/mcpacker/model/core/habitat.py @@ -39,7 +39,7 @@ class Habitat: self.location = location self.scarcity = scarcity - self._source = None + self._source:Habitat|None = None def __str__(self) -> str: return "".join([str(p) for p in [ @@ -63,17 +63,24 @@ class Habitat: "}" ]]) - def derive(self, **kwargs): - kwargs = { - "altitude": self.altitude, - "biomeFilter": self.biomeFilter, - "seasons": self.seasons, - "group": self.group, - "location": self.location, - "scarcity": self.scarcity - } | kwargs - - result = Habitat(**kwargs) + def derive( + self, + *, + altitude:Altitude|None=None, + biomeFilter:BiomeFilter|None=None, + seasons:Iterable[Season]|Season|None=None, + group:Group|None=None, + location:Location|None=None, + scarcity:Scarcity|None=None, + ): + result = Habitat( + altitude or self.altitude, + biomeFilter or self.biomeFilter, + seasons or self.seasons, + group or self.group, + location or self.location, + scarcity or self.scarcity + ) result._source = self return result diff --git a/mcpacker/model/datapack/__init__.py b/mcpacker/model/datapack/__init__.py index 2a2cf20..07f72c1 100644 --- a/mcpacker/model/datapack/__init__.py +++ b/mcpacker/model/datapack/__init__.py @@ -5,6 +5,11 @@ from mcpacker.model.datapack.moddata import ModData # Class ############################################################################################ class DataPack: + """ + A collection of data used to configure a Minecraft instance. + + see: https://minecraft.wiki/w/Data_pack + """ def __init__(self, name:str, mods:Iterable[ModData]|None=None): self.name = name diff --git a/mcpacker/model/datapack/recipe/__init__.py b/mcpacker/model/datapack/recipe/__init__.py new file mode 100644 index 0000000..200e87b --- /dev/null +++ b/mcpacker/model/datapack/recipe/__init__.py @@ -0,0 +1,20 @@ +from collections.abc import Iterable +from collections.abc import Mapping +from mcpacker.model.core.resourceid import ResourceId + + +# Class ############################################################################################ + +class Recipe: + + def __init__( + self, + gameId:str|ResourceId, + resultId:str|ResourceId, + resultCount:int=1, + resultComponents:Mapping[str,str]|None=None, + ): + self.gameId = ResourceId.parse(gameId) + self.resultComponents = resultComponents + self.resultCount = resultCount + self.resultId = ResourceId.parse(resultId) diff --git a/mcpacker/model/datapack/recipe/shapedrecipe.py b/mcpacker/model/datapack/recipe/shapedrecipe.py new file mode 100644 index 0000000..0608b7a --- /dev/null +++ b/mcpacker/model/datapack/recipe/shapedrecipe.py @@ -0,0 +1,56 @@ +from collections.abc import Iterable +from collections.abc import Mapping +from mcpacker.model.datapack.recipe import Recipe +from mcpacker.model.core.resourceid import ResourceId + + +# Class ############################################################################################ + +class ShapedRecipe(Recipe): + + def __init__( + self, + gameId:str|ResourceId, + key:Mapping[str,(str|ResourceId|Iterable[ResourceId|str])], + pattern:Iterable[str], + resultId:str|ResourceId, + resultCount:int=1, + resultComponents:Mapping[str,str]|None=None, + ): + super().__init__(gameId, resultId, resultCount, resultComponents) + self.key = self._parseKey(key) + self.pattern = self._parsePattern(pattern) + + # Private Methods ########################################################## + + def _parseKey( + self, + key:Mapping[str,(str|ResourceId|Iterable[ResourceId|str])] + ) -> dict[str, list[ResourceId]]: + result:dict[str, list[ResourceId]] = {} + + for name, value in key.items(): + finalValue:list[ResourceId] = [] + + if isinstance(value, str) or isinstance(value, ResourceId): + finalValue = [ ResourceId.parse(value) ] + elif isinstance(value, Iterable): + finalValue = [ ResourceId.parse(e) for e in value ] + + if len(name) != 1: + raise ValueError(f"key names must be a single letter (\"{name}\" is not valid)") + + result[name] = finalValue + + return result + + def _parsePattern(self, pattern:Iterable[str]) -> list[str]: + result:list[str] = [] + + for row in pattern: + if len(row) not in [2, 3]: + raise ValueError(f"pattern rows must be 2 or 3 letters: (\"{row}\" is not valid)") + + result.append(row) + + return result diff --git a/mcpacker/model/datapack/recipe/shapedrecipe_test.py b/mcpacker/model/datapack/recipe/shapedrecipe_test.py new file mode 100644 index 0000000..7821442 --- /dev/null +++ b/mcpacker/model/datapack/recipe/shapedrecipe_test.py @@ -0,0 +1,27 @@ +from pytest import fixture +from mcpacker.model.datapack.recipe.shapedrecipe import ShapedRecipe + + +# Fixtures ######################################################################################### + +@fixture(name="recipe") +def createRecipe(): + yield ShapedRecipe( + "iron_pickaxe", + {"s": "stick", "i": "iron_ingot"}, + ["iii", " s ", " s "], + "iron_pickaxe" + ) + + +# Tests ############################################################################################ + +def test_recipe(recipe:ShapedRecipe): + keyText = ", ".join(f"{k}:{str(v[0])}" for k,v in recipe.key.items()) + recipeText = "|".join(str(i) for i in recipe.pattern) + + assert keyText == "s:minecraft:stick, i:minecraft:iron_ingot" + assert recipeText == "iii| s | s " + assert str(recipe.resultId) == "minecraft:iron_pickaxe" + assert recipe.resultCount == 1 + diff --git a/mcpacker/model/datapack/recipe/shapelessrecipe.py b/mcpacker/model/datapack/recipe/shapelessrecipe.py new file mode 100644 index 0000000..de9eff0 --- /dev/null +++ b/mcpacker/model/datapack/recipe/shapelessrecipe.py @@ -0,0 +1,28 @@ +from collections.abc import Iterable +from collections.abc import Mapping +from mcpacker.model.core.resourceid import ResourceId +from mcpacker.model.datapack.recipe import Recipe + + +# Class ############################################################################################ + +class ShapelessRecipe(Recipe): + + def __init__( + self, + gameId:str|ResourceId, + ingredients:Iterable[str|ResourceId], + resultId:str|ResourceId, + resultCount:int=1, + resultComponents:Mapping[str,str]|None=None, + ): + super().__init__(gameId, resultId, resultCount, resultComponents) + self.ingredients = self._parseIngredients(ingredients) + + def _parseIngredients(self, ingredients:Iterable[str|ResourceId]) -> list[ResourceId]: + result = [ ResourceId.parse(e) for e in ingredients ] + + if len(result) > 9: + raise ValueError("cannot have more than 9 ingredients (\"{len(result}\" is not valid") + + return result diff --git a/mcpacker/model/datapack/recipe/shapelessrecipe_test.py b/mcpacker/model/datapack/recipe/shapelessrecipe_test.py new file mode 100644 index 0000000..9b8e08d --- /dev/null +++ b/mcpacker/model/datapack/recipe/shapelessrecipe_test.py @@ -0,0 +1,17 @@ +from pytest import fixture +from mcpacker.model.datapack.recipe.shapelessrecipe import ShapelessRecipe + + +# Fixtures ######################################################################################### + +@fixture(name="recipe") +def createRecipe(): + yield ShapelessRecipe("flint_and_steel", ["iron_ingot", "flint"], "flint_and_steel") + + +# Tests ############################################################################################ + +def test_recipe(recipe:ShapelessRecipe): + assert ", ".join(str(i) for i in recipe.ingredients) == "minecraft:iron_ingot, minecraft:flint" + assert str(recipe.resultId) == "minecraft:flint_and_steel" + assert recipe.resultCount == 1 diff --git a/mcpacker/model/modpack.py b/mcpacker/model/modpack.py index 072c977..77d9c90 100644 --- a/mcpacker/model/modpack.py +++ b/mcpacker/model/modpack.py @@ -1,9 +1,15 @@ -from collections.abc import Iterable -from mcpacker.model.core.world import World -from mcpacker.model.datapack import DataPack -from mcpacker.model.mod import Mod +from collections.abc import Iterable +from mcpacker.model.core.world import World +from mcpacker.model.datapack import DataPack +from mcpacker.model.mod import Mod from mcpacker.model.resourcepack import ResourcePack -from typing import Callable +from typing import Callable +from typing import Self + + +# Type Helpers ##################################################################################### + +type AugmentFunc = Callable[[ModPack], None] # Class ############################################################################################ @@ -27,6 +33,6 @@ class ModPack: for mod in (mods or []): self._mods[mod.name] = mod - def augment(self, doAugment:"Callable[[ModPack], None]") -> "ModPack": + def augment(self, doAugment:AugmentFunc) -> Self: doAugment(self) return self diff --git a/mcpacker/ui/runner.py b/mcpacker/ui/runner.py index 8243d35..2abc47e 100644 --- a/mcpacker/ui/runner.py +++ b/mcpacker/ui/runner.py @@ -1,15 +1,14 @@ -from mcpacker.model.modpack import ModPack +from mcpacker.format.report.biomereport import BiomeReport +from mcpacker.format.report.mineralreport import MineralReport +from mcpacker.format.report.mobspawnreport import MobSpawnReport +from mcpacker.model.modpack import ModPack +from mcpacker.write.compositewriter import CompositeWriter from mcpacker.write.incontrol.spawnerwriter import SpawnerWriter -from mcpacker.write.markdown.biomereport import BiomeReport -from mcpacker.write.markdown.mineralreport import MineralReport -from mcpacker.write.markdown.mobspawnreport import MobSpawnReport -from mcpacker.write.markdown.reportwriter import ReportWriter -from mcpacker.write.compositewriter import CompositeWriter -from mcpacker.write.staticwriter import StaticWriter -from pathlib import Path +from mcpacker.write.report.writer import ReportWriter +from mcpacker.write.staticwriter import StaticWriter +from pathlib import Path import inspect -import os import sys @@ -46,13 +45,13 @@ class Runner: def _command_writeReports(self): CompositeWriter(self.pack, self.outputDir, [ - BiomeReport, - MineralReport, - MobSpawnReport, + ReportWriter(self.pack, BiomeReport, self.outputDir, "biomes.md"), + ReportWriter(self.pack, MineralReport, self.outputDir, "minerals.md"), + ReportWriter(self.pack, MobSpawnReport, self.outputDir, "mobspawns.md"), ]).write() def _command_writeModPack(self): CompositeWriter(self.pack, self.outputDir, [ - StaticWriter, - SpawnerWriter, + StaticWriter(self.pack, self.outputDir), + SpawnerWriter(self.pack, self.outputDir), ]).write() diff --git a/mcpacker/ui/runner_test.py b/mcpacker/ui/runner_test.py index 9f17cbf..f622299 100644 --- a/mcpacker/ui/runner_test.py +++ b/mcpacker/ui/runner_test.py @@ -1,29 +1,29 @@ -from mcpacker.model.core.ecology.biome import Biome +from mcpacker.model.core.ecology.biome import Biome from mcpacker.model.core.ecology.biomefilter import BiomeFilter as BF -from mcpacker.model.core.fauna.mob import Mob -from mcpacker.model.core.fauna.mobspawn import MobSpawn -from mcpacker.model.core.geology.mineral import Mineral +from mcpacker.model.core.fauna.mob import Mob +from mcpacker.model.core.fauna.mobspawn import MobSpawn +from mcpacker.model.core.geology.mineral import Mineral from mcpacker.model.core.geology.replacement import Replacement -from mcpacker.model.core.habitat import Habitat -from mcpacker.model.modpack import ModPack -from mcpacker.ui.runner import Runner -from mcpacker.write.incontrol.spawnerwriter import SpawnerWriter -from pathlib import Path -from pytest import fixture +from mcpacker.model.core.habitat import Habitat +from mcpacker.model.modpack import ModPack +from mcpacker.ui.runner import Runner +from mcpacker.write.incontrol.spawnerwriter import SpawnerWriter +from pathlib import Path +from pytest import fixture -import mcpacker.json as json -import mcpacker.model.core.altitude as AL -import mcpacker.model.core.ecology.flora as FL -import mcpacker.model.core.ecology.geology as GE -import mcpacker.model.core.ecology.heat as HE +import mcpacker.json as json +import mcpacker.model.core.altitude as AL +import mcpacker.model.core.ecology.flora as FL +import mcpacker.model.core.ecology.geology as GE +import mcpacker.model.core.ecology.heat as HE import mcpacker.model.core.ecology.humidity as HU -import mcpacker.model.core.ecology.soil as SO -import mcpacker.model.core.ecology.water as WA -import mcpacker.model.core.fauna.active as AC -import mcpacker.model.core.fauna.group as GR -import mcpacker.model.core.fauna.location as LO -import mcpacker.model.core.scarcity as SC -import mcpacker.model.core.season as SE +import mcpacker.model.core.ecology.soil as SO +import mcpacker.model.core.ecology.water as WA +import mcpacker.model.core.fauna.active as AC +import mcpacker.model.core.fauna.group as GR +import mcpacker.model.core.fauna.location as LO +import mcpacker.model.core.scarcity as SC +import mcpacker.model.core.season as SE import textwrap diff --git a/mcpacker/write/compositewriter.py b/mcpacker/write/compositewriter.py index 97fdc09..ed41948 100644 --- a/mcpacker/write/compositewriter.py +++ b/mcpacker/write/compositewriter.py @@ -1,16 +1,7 @@ -from collections.abc import Iterable +from collections.abc import Iterable from mcpacker.model.modpack import ModPack -from mcpacker.write.writer import Writer -from pathlib import Path -from typing import TypeVar - -import os -import shutil - - -# Type Support ##################################################################################### - -WriterSubclass = TypeVar("WriterSubclass", bound=Writer) +from mcpacker.write.writer import Writer +from pathlib import Path # Class ############################################################################################ @@ -21,16 +12,10 @@ class CompositeWriter(Writer): self, pack:ModPack, outputDir:Path, - writers:Iterable[type[WriterSubclass]]|None=None + writers:Iterable[Writer]|None=None ): super().__init__(pack, outputDir) - - self._writers:list[Writer] = [] - for writerClass in (writers or []): - self.add(writerClass) - - def add(self, writerClass:type[WriterSubclass]): - self._writers.append(writerClass(self.pack, self.outputDir)) + self._writers:list[Writer] = list(w for w in writers or []) def write(self): for writer in self._writers: diff --git a/mcpacker/write/markdown/markdownwriter.py b/mcpacker/write/markdown/markdownwriter.py deleted file mode 100644 index acf33e7..0000000 --- a/mcpacker/write/markdown/markdownwriter.py +++ /dev/null @@ -1,71 +0,0 @@ -from mcpacker.write.writer import Writer -from mcpacker.model.modpack import ModPack -from pathlib import Path -from typing import TextIO - -import os -import shutil - - -# Constants ######################################################################################## - -INDENT = " " -REPORT_DIR_PATH = "reports" - - -# Class ############################################################################################ - -class MarkdownWriter(Writer): - - def __init__(self, name:str, pack:ModPack, outputDir:Path): - super().__init__(pack, outputDir) - - self._file:TextIO|None = None - self._name = name - self._indent = 0 - self._lineBuffer:list[str] = [] - - def write(self): - path = self.outputDir / self.pack.name / REPORT_DIR_PATH / self._name - - path.parent.mkdir(exist_ok=True, parents=True) - with path.open("w") as file: - try: - self._file = file - self.compose() - finally: - self._file = None - - def compose(self) -> str: - raise NotImplementedError() - - # Helper Methods ########################################################### - - def indent(self) -> "MarkdownWriter": - self._indent += 1 - self._lineBuffer.insert(0, INDENT) - return self - - def line(self, text:str="") -> "MarkdownWriter": - assert self._file != None - self.text(text) - - self._file.write("".join(self._lineBuffer) + "\n") - self._lineBuffer = [] - while len(self._lineBuffer) < self._indent: - self._lineBuffer.append(INDENT) - - return self - - def outdent(self) -> "MarkdownWriter": - self._indent = max(0, self._indent - 1) - - if self._lineBuffer: - if self._lineBuffer[0] == INDENT: - self._lineBuffer.pop() - - return self - - def text(self, text:str) -> "MarkdownWriter": - self._lineBuffer.append(str(text)) - return self diff --git a/mcpacker/write/markdown/markdownwriter_test.py b/mcpacker/write/markdown/markdownwriter_test.py deleted file mode 100644 index d0ba28c..0000000 --- a/mcpacker/write/markdown/markdownwriter_test.py +++ /dev/null @@ -1,43 +0,0 @@ -from mcpacker.model.modpack import ModPack -from mcpacker.write.markdown.markdownwriter import MarkdownWriter -from pathlib import Path -from pytest import fixture - -import textwrap - - -# Helpers ########################################################################################## - -class SampleWriter(MarkdownWriter): - - def compose(self): - (self.text("alpha") - .line("bravo") - .indent() - .line("charlie") - .indent() - .line("delta") - .outdent() - .line("echo") - ) - - -# Fixtures ######################################################################################### - -@fixture(name="writer") -def createWriter(tmp_path:Path): - writer = SampleWriter("testReport.md", ModPack("testModPack"), tmp_path) - writer.write() - yield writer - - -# Tests ############################################################################################ - -def test_write(tmp_path, writer): - path = tmp_path / "testModPack" / "reports" / "testReport.md" - assert path.read_text() == textwrap.dedent(""" - alphabravo - charlie - delta - echo - """).lstrip() diff --git a/mcpacker/write/markdown/report.py b/mcpacker/write/markdown/report.py deleted file mode 100644 index 0703daf..0000000 --- a/mcpacker/write/markdown/report.py +++ /dev/null @@ -1,72 +0,0 @@ -from collections.abc import Iterator -from mcpacker.model.modpack import ModPack - - -# Constants ######################################################################################## - -INDENT = " " - - -# Class ############################################################################################ - -class Report: - - def __init__(self, name:str, pack:ModPack): - self.name = name - self.pack = pack - self.reset() - - def __str__(self) -> str: - return "\n".join(self._lines).strip() - - def __repr__(self) -> str: - return ( - "Report{" + - f"lines: {len(self._lines)} lines, " + - f"lineBuffer: {len(self._lineBuffer)} chunks, " + - f"indent: {self._indent}" + - "}" - ) - - def build(self): - raise NotImplementedError() - - def reset(self) -> "Report": - self._lineBuffer:list[str] = [] - self._lines:list[str] = [] - self._indent = 0 - return self - - def asLines(self) -> Iterator[str]: - for line in self._lines: - yield line - - # Helper Methods ########################################################### - - def indent(self) -> "Report": - self._indent += 1 - self._lineBuffer.insert(0, INDENT) - return self - - def line(self, text:str="") -> "Report": - self.text(text) - - self._lines.append("".join(self._lineBuffer)) - self._lineBuffer = [] - while len(self._lineBuffer) < self._indent: - self._lineBuffer.append(INDENT) - - return self - - def outdent(self) -> "Report": - self._indent = max(0, self._indent - 1) - - if self._lineBuffer: - if self._lineBuffer[0] == INDENT: - self._lineBuffer.pop() - - return self - - def text(self, text:str) -> "Report": - self._lineBuffer.append(str(text)) - return self diff --git a/mcpacker/write/markdown/report_test.py b/mcpacker/write/markdown/report_test.py deleted file mode 100644 index e98f298..0000000 --- a/mcpacker/write/markdown/report_test.py +++ /dev/null @@ -1,27 +0,0 @@ -from mcpacker.write.markdown.report import Report -from pytest import fixture - - -# Fixtures ######################################################################################### - -@fixture(name="report") -def createReport(): - return ( - Report("test", None) - .text("alpha") - .line("bravo") - .indent() - .line("charlie") - .indent() - .line("delta") - .outdent() - .line("echo") - ) - -# Tests ############################################################################################ - -def test_repr(report): - assert repr(report) == "Report{lines: 4 lines, lineBuffer: 1 chunks, indent: 1}" - -def test_str(report): - assert str(report) == "alphabravo\n charlie\n delta\n echo" diff --git a/mcpacker/write/markdown/reportwriter.py b/mcpacker/write/markdown/reportwriter.py deleted file mode 100644 index 25974ae..0000000 --- a/mcpacker/write/markdown/reportwriter.py +++ /dev/null @@ -1,25 +0,0 @@ -from mcpacker.write.markdown.report import Report - -import os -import shutil - - -# Class ############################################################################################ - -class ReportWriter: - - def __init__(self, reports:list[Report]): - self._reports = reports - - def write(self, reportDirPath:str): - if os.path.exists(reportDirPath): - shutil.rmtree(reportDirPath) - - os.makedirs(reportDirPath, exist_ok=True) - - for report in self._reports: - report.build() - - path = os.path.join(reportDirPath, report.name) - with open(path, "w") as file: - file.write(str(report)) diff --git a/mcpacker/write/report/__init__.py b/mcpacker/write/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcpacker/write/report/writer.py b/mcpacker/write/report/writer.py new file mode 100644 index 0000000..174dabf --- /dev/null +++ b/mcpacker/write/report/writer.py @@ -0,0 +1,31 @@ +from mcpacker.model.modpack import ModPack +from mcpacker.format.report.composer import ReportComposer +from mcpacker.write.writer import Writer +from pathlib import Path +from typing import Self + + +# Class ############################################################################################ + +class ReportWriter(Writer): + + def __init__( + self, + pack:ModPack, + composerClass:type[ReportComposer], + outputDir:Path, + name:str|Path + ): + super().__init__(pack, outputDir) + self.composerClass = composerClass + self.name = Path(name) + + def write(self) -> Self: + path = self.outputDir/self.pack.name/"reports"/self.name + self.resetOutputFile(path) + + composer = self.composerClass(self.pack) + with path.open("w") as file: + file.write(str(composer.compose())) + + return self diff --git a/mcpacker/write/textwriter.py b/mcpacker/write/textwriter.py new file mode 100644 index 0000000..94e6840 --- /dev/null +++ b/mcpacker/write/textwriter.py @@ -0,0 +1,26 @@ +from mcpacker.format.textcomposer import TextComposer +from mcpacker.model.modpack import ModPack +from mcpacker.write.writer import Writer +from pathlib import Path +from typing import Self + +import os + + +# Class ############################################################################################ + +class TextWriter(Writer): + + def __init__(self, pack:ModPack, outputDir:Path, filePath:Path, composer:TextComposer): + super().__init__(pack, outputDir) + self.composer = composer + self.filePath = filePath + + def write(self) -> Self: + path = self.outputDir/self.pack.name/self.filePath + self.resetOutputFile(path) + + with path.open("w") as file: + file.write(str(self.composer.compose())) + + return self diff --git a/mcpacker/write/textwriter_test.py b/mcpacker/write/textwriter_test.py new file mode 100644 index 0000000..9af72a3 --- /dev/null +++ b/mcpacker/write/textwriter_test.py @@ -0,0 +1,37 @@ +from mcpacker.format.textcomposer import TextComposer +from mcpacker.model.modpack import ModPack +from mcpacker.write.textwriter import TextWriter +from pathlib import Path +from pytest import fixture + + +# Helper Classes ################################################################################### + +class SampleComposer(TextComposer): + + def doCompose(self): + self.line("alpha bravo") + self.line("charlie") + + +# Fixtures ######################################################################################### + +@fixture(name="composer") +def createComposer(): + yield SampleComposer() + +@fixture(name="pack") +def createModPack(): + yield ModPack("testModPack") + +@fixture(name="writer") +def createWriter(pack:ModPack, tmp_path:Path, composer:TextComposer): + yield TextWriter(pack, tmp_path, Path("reports")/"test.md", composer).write() + + +# Tests ############################################################################################ + +def test_writer(tmp_path:Path, writer:TextWriter): + path = tmp_path/"testModPack"/"reports"/"test.md" + assert path.read_text() == "alpha bravo\ncharlie" + diff --git a/mcpacker/write/writer.py b/mcpacker/write/writer.py index 611468a..bb2cca9 100644 --- a/mcpacker/write/writer.py +++ b/mcpacker/write/writer.py @@ -1,5 +1,6 @@ from mcpacker.model.modpack import ModPack -from pathlib import Path +from pathlib import Path +from typing import Self import os import shutil @@ -18,5 +19,5 @@ class Writer: os.remove(file) file.parent.mkdir(parents=True, exist_ok=True) - def write(self): + def write(self) -> Self: raise NotImplementedError()