module; also uhh main.py

This commit is contained in:
Rowan 2025-10-06 06:31:11 -04:00
parent d646230fd6
commit e4e49d265b
7 changed files with 261 additions and 197 deletions

View file

@ -1,197 +0,0 @@
from copy import copy
from typing import BinaryIO, Callable, Iterable, Protocol, Self, TextIO, TypeAlias, runtime_checkable
from bs4 import BeautifulSoup, Tag
from dataclasses import InitVar, dataclass, field
Parsable: TypeAlias = "BeautifulSoup | Tag | str | bytes | TextIO | BinaryIO"
ElementLike: TypeAlias = "BeautifulSoup | Tag | str | Element"
ElementLikeList: TypeAlias = Iterable[ElementLike]
RenderableElement: TypeAlias = "Element | Iterable[Element]"
Templatable: TypeAlias = "Template | TemplateInstance"
TemplateDict: TypeAlias = "dict[str, Template]"
class Clonable[T = Self](Protocol):
def clone(self) -> T:
return copy(self)
# tag names
PgSlotName = "puppygirl-slot"
TemplateName = "template"
SlotName = "slot"
IdAttr = "id"
NameAttr = "name"
TemplateAttr = "template"
UnnamedSlotId = "unnamed"
@dataclass
class Element(Clonable):
value: InitVar[ElementLike]
def __post_init__(self, value: ElementLike):
if isinstance(value, str):
value = BeautifulSoup(value, features = "html.parser")
if isinstance(value, BeautifulSoup):
value = next(iter(value))
self.value = value
def from_element_like(value: ElementLike) -> Self:
if isinstance(value, Element):
return value
return Element(value)
def clone(self) -> Self:
return Element(copy(self.value))
# proxy all calls to inner template
def __getattr__(self, name):
return getattr(self.value, name)
def __getitem__(self, index):
return self.value[index]
@dataclass
class Template(Element, Clonable["TemplateInstance"]):
def __post_init__(self, value: ElementLike):
Element.__post_init__(self, value)
if value.name != TemplateName:
raise TypeError(f"{value} is not a template")
if not value.has_attr(IdAttr):
raise TypeError(f"{value} missing id attribute")
def clone(self) -> "TemplateInstance":
return TemplateInstance(copy(self.value))
class TemplateSlot(Element):
is_default: bool = True
def append(self, value):
if self.is_default:
self.is_default = False
self.value.clear()
self.value.append(value)
@dataclass
class TemplateInstance(Element, Clonable):
slots: dict[str, Tag] = field(init = False)
slot_instances: dict[str, TemplateSlot] = field(default_factory=dict)
def __post_init__(self, value: ElementLike):
Element.__post_init__(self, value)
slots = {}
for slot in value.find_all(SlotName):
if slot.has_attr(NameAttr):
slots[slot[NameAttr]] = slot
else:
slots[UnnamedSlotId] = slot
self.slots = slots
def _get_slot(self, slot_name: str) -> TemplateSlot:
slot = self.slot_instances.get(slot_name)
if slot is not None: return slot
slot = TemplateSlot(self.slots.get(slot_name))
self.slot_instances[slot_name] = slot
return slot
def insert_content(self, content: ElementLike):
slot_name = content.get(SlotName)
if slot_name is None:
slot_name = UnnamedSlotId
slot = self._get_slot(slot_name)
if slot is not None:
slot.append(content)
def remove_slots(self):
for slot in self.slots.values():
slot.unwrap()
@runtime_checkable
class Renderable(Protocol):
def render(self, element: Element, templates: TemplateDict) -> RenderableElement:
return element
@dataclass
class PuppygirlTag(Renderable):
puppygirl: "Puppygirl"
@dataclass
class PuppygirlSlot(PuppygirlTag):
name: str = "puppygirl-slot"
def apply_template(self, element: Element, template: Clonable[TemplateInstance]) -> Iterable[Element]:
instance = template.clone()
for content in element.find_all(recursive=False):
instance.insert_content(content)
del content[SlotName]
instance.remove_slots()
return instance.value
def render(self, element: Element, templates: TemplateDict) -> RenderableElement:
if not element.has_attr(TemplateAttr):
return element
template = templates.get(element[TemplateAttr])
return self.apply_template(element, template)
class Puppygirl:
elements: list[Renderable]
templates: TemplateDict
def __init__(self, elements: list[Callable[[Self], Renderable]] = [], templates: ElementLikeList = []):
self.templates = Puppygirl._create_template_dict(templates)
self.elements = [self._instantiate(el) for el in elements]
def _instantiate(self, value: Callable[[Self], Renderable] | Renderable) -> Renderable:
if(isinstance(value, Callable)):
return value(self)
return value
def _create_template_dict(templates: Iterable[ElementLike]) -> TemplateDict:
templates = [Template(t) for t in templates]
return {t[IdAttr]: t for t in templates}
def add_template(self, template: ElementLike):
template = Template(template)
self._templates[template[IdAttr]] = template
def _find_local_templates(tree: BeautifulSoup) -> TemplateDict:
templates = tree.find_all(TemplateName)
templates = filter(lambda t: t.has_attr(IdAttr), templates)
return Puppygirl._create_template_dict(templates)
def fetch(self, path: str) -> BeautifulSoup:
with open(path, "r") as f:
return self.parse(f)
def parse(self, value: Parsable) -> BeautifulSoup:
if isinstance(value, BeautifulSoup) or isinstance(value, Tag):
return self.parse_tree(value)
return self.parse_tree(BeautifulSoup(value, features='html.parser'))
def parse_tree(self, tree: BeautifulSoup) -> BeautifulSoup:
templates = Puppygirl._find_local_templates(tree) | self.templates
for element in self.elements:
if hasattr(element, "name"):
for tag in tree.find_all(element.name):
new_tag = element.render(tag, templates)
if isinstance(new_tag, Iterable):
tag.extend(new_tag)
tag.unwrap()
else:
tag.replace_with(new_tag)
return tree

28
puppygirl/__main__.py Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env python3
from argparse import ArgumentParser
from puppygirl import Puppygirl
from elements.tag import PuppygirlTag
parser = ArgumentParser("puppygirl", add_help=True)
subparsers = parser.add_subparsers(dest="command")
build = subparsers.add_parser("build", add_help=True)
build.add_argument("input")
build.add_argument("output")
build.add_argument("-p", "--pretty", action="store_true")
def main():
args = parser.parse_args()
print(vars(args))
pg = Puppygirl([PuppygirlTag])
html = pg.fetch(args.input)
if args.pretty:
html = html.prettify()
else:
html = str(html)
with open(args.output, "w") as f:
f.write(html)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,117 @@
from copy import copy
from dataclasses import InitVar, dataclass, field
from typing import TYPE_CHECKING, Self
from bs4 import BeautifulSoup, Tag
from protocols import Clonable, Renderable
if TYPE_CHECKING:
from puppygirl.puppygirl import Puppygirl
from puppygirl.puppytype import ElementLike
PgSlotName = "puppygirl-slot"
TemplateName = "template"
SlotName = "slot"
IdAttr = "id"
NameAttr = "name"
TemplateAttr = "template"
UnnamedSlotId = "unnamed"
@dataclass
class PuppygirlElement(Renderable):
puppygirl: "Puppygirl"
@dataclass
class Element(Clonable):
value: InitVar["ElementLike"]
def __post_init__(self, value: "ElementLike"):
if isinstance(value, str):
value = BeautifulSoup(value, features = "html.parser")
if isinstance(value, BeautifulSoup):
value = next(iter(value))
self.value = value
def from_element_like(value: "ElementLike") -> Self:
if isinstance(value, Element):
return value
return Element(value)
def clone(self) -> Self:
return Element(copy(self.value))
# proxy all calls to inner template
def __getattr__(self, name):
return getattr(self.value, name)
def __getitem__(self, index):
return self.value[index]
class TemplateSlot(Element):
is_default: bool = True
def append(self, value):
if self.is_default:
self.is_default = False
self.value.clear()
self.value.append(value)
@dataclass
class TemplateInstance(Element, Clonable):
slots: dict[str, Tag] = field(init = False)
slot_instances: dict[str, TemplateSlot] = field(default_factory=dict)
def __post_init__(self, value: "ElementLike"):
Element.__post_init__(self, value)
slots = {}
for slot in value.find_all(SlotName):
if slot.has_attr(NameAttr):
slots[slot[NameAttr]] = slot
else:
slots[UnnamedSlotId] = slot
self.slots = slots
def _get_slot(self, slot_name: str) -> TemplateSlot:
slot = self.slot_instances.get(slot_name)
if slot is not None: return slot
slot = TemplateSlot(self.slots.get(slot_name))
self.slot_instances[slot_name] = slot
return slot
def insert_content(self, content: "ElementLike"):
slot_name = content.get(SlotName)
if slot_name is None:
slot_name = UnnamedSlotId
slot = self._get_slot(slot_name)
if slot is not None:
slot.append(content)
def remove_slots(self):
for slot in self.slots.values():
slot.unwrap()
@dataclass
class Template(Element, Clonable[TemplateInstance]):
def __post_init__(self, value: "ElementLike"):
Element.__post_init__(self, value)
if value.name != TemplateName:
raise TypeError(f"{value} is not a template")
if not value.has_attr(IdAttr):
raise TypeError(f"{value} missing id attribute")
def clone(self) -> TemplateInstance:
return TemplateInstance(copy(self.value))

30
puppygirl/elements/tag.py Normal file
View file

@ -0,0 +1,30 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from elements import PuppygirlElement, SlotName, TemplateAttr
if TYPE_CHECKING:
from elements import Element, TemplateInstance
from protocols import Clonable
from puppytype import RenderableElement, TemplateDict
@dataclass
class PuppygirlTag(PuppygirlElement):
name: str = "puppygirl-tag"
def apply_template(self, element: "Element", template: "Clonable[TemplateInstance]") -> Iterable["Element"]:
instance = template.clone()
for content in element.find_all(recursive=False):
instance.insert_content(content)
del content[SlotName]
instance.remove_slots()
return instance.value
def render(self, element: "Element", templates: "TemplateDict") -> "RenderableElement":
if not element.has_attr(TemplateAttr):
return element
template = templates.get(element[TemplateAttr])
return self.apply_template(element, template)

16
puppygirl/protocols.py Normal file
View file

@ -0,0 +1,16 @@
from copy import copy
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable
if TYPE_CHECKING:
from elements import Element
from puppytype import RenderableElement, TemplateDict
class Clonable[T = Self](Protocol):
def clone(self) -> T:
return copy(self)
@runtime_checkable
class Renderable(Protocol):
def render(self, element: "Element", templates: "TemplateDict") -> "RenderableElement":
return element

56
puppygirl/puppygirl.py Normal file
View file

@ -0,0 +1,56 @@
from typing import Callable, Iterable, Self
from bs4 import BeautifulSoup, Tag
from elements import IdAttr, Renderable, Template, TemplateName
from puppytype import ElementLike, ElementLikeList, Parsable, TemplateDict
class Puppygirl:
elements: list[Renderable]
templates: TemplateDict
def __init__(self, elements: list[Callable[[Self], Renderable]] = [], templates: ElementLikeList = []):
self.templates = Puppygirl._create_template_dict(templates)
self.elements = [self._instantiate(el) for el in elements]
def _instantiate(self, value: Callable[[Self], Renderable] | Renderable) -> Renderable:
if(isinstance(value, Callable)):
return value(self)
return value
def _create_template_dict(templates: Iterable[ElementLike]) -> TemplateDict:
templates = [Template(t) for t in templates]
return {t[IdAttr]: t for t in templates}
def add_template(self, template: ElementLike):
template = Template(template)
self._templates[template[IdAttr]] = template
def _find_local_templates(tree: BeautifulSoup) -> TemplateDict:
templates = tree.find_all(TemplateName)
templates = filter(lambda t: t.has_attr(IdAttr), templates)
return Puppygirl._create_template_dict(templates)
def fetch(self, path: str) -> BeautifulSoup:
with open(path, "r") as f:
return self.parse(f)
def parse(self, value: Parsable) -> BeautifulSoup:
if isinstance(value, BeautifulSoup) or isinstance(value, Tag):
return self.parse_tree(value)
return self.parse_tree(BeautifulSoup(value, features='html.parser'))
def parse_tree(self, tree: BeautifulSoup) -> BeautifulSoup:
templates = Puppygirl._find_local_templates(tree) | self.templates
for element in self.elements:
if hasattr(element, "name"):
for tag in tree.find_all(element.name):
new_tag = element.render(tag, templates)
if isinstance(new_tag, Iterable):
tag.extend(new_tag)
tag.unwrap()
else:
tag.replace_with(new_tag)
return tree

14
puppygirl/puppytype.py Normal file
View file

@ -0,0 +1,14 @@
from typing import BinaryIO, Iterable, TextIO, TypeAlias, TYPE_CHECKING
if TYPE_CHECKING:
from string import Template
from bs4 import BeautifulSoup, Tag
from puppygirl.puppygirl import TemplateInstance
from puppygirl.elements import Element
Parsable: TypeAlias = "BeautifulSoup | Tag | str | bytes | TextIO | BinaryIO"
ElementLike: TypeAlias = "BeautifulSoup | Tag | str | Element"
ElementLikeList: TypeAlias = "Iterable[ElementLike]"
RenderableElement: TypeAlias = "Element | Iterable[Element]"
Templatable: TypeAlias = "Template | TemplateInstance"
TemplateDict: TypeAlias = "dict[str, Template]"