This commit is contained in:
Rowan 2025-10-06 16:11:34 -04:00
parent e4e49d265b
commit 698ed22f72
19 changed files with 291 additions and 196 deletions

View file

@ -0,0 +1,4 @@
Metadata-Version: 2.4
Name: puppygirl
Version: 0.0.0
Requires-Dist: beautifulsoup4~=4.14.2

View file

@ -0,0 +1,16 @@
pyproject.toml
puppygirl/__init__.py
puppygirl/__main__.py
puppygirl/protocols.py
puppygirl/puppygirl.py
puppygirl/puppytype.py
puppygirl/renderer.py
puppygirl.egg-info/PKG-INFO
puppygirl.egg-info/SOURCES.txt
puppygirl.egg-info/dependency_links.txt
puppygirl.egg-info/requires.txt
puppygirl.egg-info/top_level.txt
puppygirl/elements/__init__.py
puppygirl/elements/constants.py
puppygirl/elements/domme.py
puppygirl/elements/shadow_root.py

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@
beautifulsoup4~=4.14.2

View file

@ -0,0 +1 @@
puppygirl

View file

@ -0,0 +1,3 @@
from puppygirl.clonable import *
from puppygirl.pg import *
from puppygirl.elements import *

View file

@ -2,7 +2,8 @@
from argparse import ArgumentParser
from puppygirl import Puppygirl
from elements.tag import PuppygirlTag
from elements.domme import PuppygirlDomme
from puppygirl.renderer import ClientSideRenderer, ServerSideRenderer
parser = ArgumentParser("puppygirl", add_help=True)
subparsers = parser.add_subparsers(dest="command")
@ -14,13 +15,15 @@ build.add_argument("-p", "--pretty", action="store_true")
def main():
args = parser.parse_args()
print(vars(args))
pg = Puppygirl([PuppygirlTag])
pg = Puppygirl(renderer=ClientSideRenderer(), elements=[PuppygirlDomme])
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)

View file

@ -0,0 +1,17 @@
class PuppygirlDomme extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
const templateName = this.getAttribute('template')
const template = document.getElementById(templateName)
const content = template.content
const shadow = this.attachShadow({ mode: 'open' })
shadow.appendChild(content.cloneNode(true))
}
}
customElements.define("puppygirl-domme", PuppygirlDomme)

7
puppygirl/clonable.py Normal file
View file

@ -0,0 +1,7 @@
from copy import copy
from typing import TYPE_CHECKING, Protocol, Self
class Clonable[T = Self](Protocol):
def clone(self) -> T:
return copy(self)

View file

@ -1,117 +1,3 @@
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))
from puppygirl.elements.constants import *
from puppygirl.elements.domme import *
from puppygirl.elements.shadow_root import *

View file

@ -0,0 +1,3 @@
IdAttr = "id"
TemplateAttr = "template"
TemplateName = "template"

View file

@ -0,0 +1,31 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from puppygirl.elements import TemplateAttr
from puppygirl.elements.shadow_root import ShadowRootMode
from puppygirl.renderer import Renderable
if TYPE_CHECKING:
from puppygirl import Puppygirl
from puppygirl.elements import Node
from puppygirl.puppytype import RenderableElement, Templates
@dataclass
class PuppygirlDomme(Renderable):
puppygirl: "Puppygirl"
@property
def name(self):
return "puppygirl-domme"
def render(self, node: "Node", templates: "Templates") -> "RenderableElement":
if not node.has_attr(TemplateAttr):
return node
template = templates[node[TemplateAttr]]
shadow_root = node.attach_shadow(ShadowRootMode.Open)
shadow_root.append_child(template.clone())
return node

View file

@ -0,0 +1,118 @@
from copy import copy
from dataclasses import dataclass
from enum import StrEnum
from typing import Optional, Self
from bs4 import Tag
from puppygirl.clonable import Clonable
TemplateName = "template"
class ShadowRootMode(StrEnum):
Open = "open"
Closed = "closed"
@dataclass
class Node(Clonable):
_value: Tag
@property
def value(self):
return self._value
def attach_shadow(self, shadow_root_mode: ShadowRootMode, clonable: Optional[bool] = None, delegates_focus: Optional[bool] = None, serializable: Optional[bool] = None):
return ShadowRoot(
host=self,
mode=shadow_root_mode,
clonable=clonable,
delegates_focus=delegates_focus,
serializable=serializable
)
def clone(self) -> "Self":
return Node(copy(self.value))
def __getattr__(self, name):
if name == '_value':
raise AttributeError()
return getattr(self._value, name)
def __getitem__(self, index):
return self._value[index]
@dataclass
class Template(Clonable):
_value: Node
@property
def value(self):
return self._value
@property
def shadow_root_mode(self) -> ShadowRootMode | None:
return self._value.get("shadowrootmode")
@shadow_root_mode.setter
def shadow_root_mode(self, mode: ShadowRootMode):
self._value["shadowrootmode"] = mode.value
@property
def delegates_focus(self) -> Optional[bool]:
return self._value.get("delegatesfocus")
@delegates_focus.setter
def delegates_focus(self, value: bool):
self._value["delegatesfocus"] = value
@property
def clonable(self) -> Optional[bool]:
return self._value.get("clonable")
@clonable.setter
def clonable(self, value: bool):
self._value["clonable"] = value
@property
def serializable(self) -> Optional[bool]:
return self._value.get("serializable")
@serializable.setter
def serializable(self, value: bool):
self._value["serializable"] = value
def clone(self) -> Self:
return Template(copy(self._value))
def __getattr__(self, name):
if name == '_value':
raise AttributeError()
return getattr(self._value, name)
def __getitem__(self, index):
return self._value[index]
def __setitem__(self, index, value):
self._value[index] = value
@dataclass
class ShadowRoot(Clonable):
host: "Node"
mode: ShadowRootMode
clonable: Optional[bool] = None
delegates_focus: Optional[bool] = None
serializable: Optional[bool] = None
def append_child(self, template: Template):
template.shadow_root_mode = self.mode
if self.clonable is not None:
template.clonable = self.clonable
if self.delegates_focus is not None:
template.delegates_focus = self.delegates_focus
if self.serializable is not None:
template.serializable = self.serializable
self.host.value.insert(0, template.value)

View file

@ -1,30 +0,0 @@
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)

View file

@ -1,14 +1,17 @@
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
from elements import IdAttr, Template
from puppygirl.renderer import Renderable, Renderer
from puppygirl.puppytype import ElementLike, ElementLikeList, Parsable, Templates
class Puppygirl:
class Puppygirl[R: Renderer]:
renderer: R
elements: list[Renderable]
templates: TemplateDict
templates: Templates
def __init__(self, elements: list[Callable[[Self], Renderable]] = [], templates: ElementLikeList = []):
def __init__(self, renderer: R, elements: list[Callable[[Self], Renderable]] = [], templates: ElementLikeList = []):
self.renderer = renderer
self.templates = Puppygirl._create_template_dict(templates)
self.elements = [self._instantiate(el) for el in elements]
@ -17,7 +20,7 @@ class Puppygirl:
return value(self)
return value
def _create_template_dict(templates: Iterable[ElementLike]) -> TemplateDict:
def _create_template_dict(templates: Iterable[ElementLike]) -> Templates:
templates = [Template(t) for t in templates]
return {t[IdAttr]: t for t in templates}
@ -25,11 +28,6 @@ class Puppygirl:
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)
@ -41,16 +39,4 @@ class Puppygirl:
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
return self.renderer.render(self, tree)

View file

@ -1,16 +0,0 @@
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

View file

@ -3,12 +3,10 @@ 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
from puppygirl.elements import Node
Parsable: TypeAlias = "BeautifulSoup | Tag | str | bytes | TextIO | BinaryIO"
ElementLike: TypeAlias = "BeautifulSoup | Tag | str | Element"
ElementLike: TypeAlias = "BeautifulSoup | Tag | str | Node"
ElementLikeList: TypeAlias = "Iterable[ElementLike]"
RenderableElement: TypeAlias = "Element | Iterable[Element]"
Templatable: TypeAlias = "Template | TemplateInstance"
TemplateDict: TypeAlias = "dict[str, Template]"
RenderableElement: TypeAlias = "Node | Iterable[Node]"
Templates: TypeAlias = "dict[str, Template]"

59
puppygirl/renderer.py Normal file
View file

@ -0,0 +1,59 @@
from dataclasses import InitVar, dataclass, field
from importlib import resources
from typing import TYPE_CHECKING, Iterable, Protocol, runtime_checkable
from puppygirl.elements import IdAttr, TemplateName
from puppygirl.elements.shadow_root import Node
from puppygirl.puppytype import Templates
if TYPE_CHECKING:
from bs4 import BeautifulSoup
from puppygirl import Puppygirl
@runtime_checkable
class Renderable(Protocol):
def render(self, tree: "BeautifulSoup") -> "BeautifulSoup":
pass
class Renderer(Protocol):
def render(self, puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "BeautifulSoup":
pass
@dataclass
class ClientSideRenderer(Renderer):
path: InitVar[str] = 'clientside.js'
injected_js: str = field(init = False)
def __post_init__(self, path: str):
with resources.open_text('puppygirl.assets', path) as f:
self.injected_js = f.read()
def render(self, puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "BeautifulSoup":
head = tree.find("head")
script = tree.new_tag("script", attrs={"type": "text/javascript"})
script.append(self.injected_js)
head.append(script)
return tree
class ServerSideRenderer(Renderer):
def _find_local_templates(puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "Templates":
templates = tree.find_all(TemplateName)
templates = filter(lambda t: t.has_attr(IdAttr), templates)
return puppygirl.__class__._create_template_dict(templates)
def render(self, puppygirl: "Puppygirl", tree: "BeautifulSoup") -> "BeautifulSoup":
templates = ServerSideRenderer._find_local_templates(puppygirl, tree) | puppygirl.templates
for element in puppygirl.elements:
if hasattr(element, "name"):
for tag in tree.find_all(element.name):
new_tag = element.render(Node(tag).clone(), templates)
if isinstance(new_tag, Iterable):
tag.extend(new_tag)
tag.unwrap()
else:
tag.replace_with(new_tag.value)
return tree

7
pyproject.toml Normal file
View file

@ -0,0 +1,7 @@
[project]
name = "puppygirl"
dynamic = ["version"]
dependencies = [
"beautifulsoup4~=4.14.2"
]