diff --git a/src/py/extra/client.py b/src/py/extra/client.py index 1213a92..4f30939 100644 --- a/src/py/extra/client.py +++ b/src/py/extra/client.py @@ -1,4 +1,4 @@ -from typing import NamedTuple, ClassVar, AsyncGenerator +from typing import NamedTuple, ClassVar, AsyncGenerator, Self, Any, Iterator from urllib.parse import quote_plus, urlparse from contextvars import ContextVar from contextlib import contextmanager @@ -13,9 +13,10 @@ HTTPRequestBody, HTTPBodyBlob, HTTPAtom, + HTTPProcessingStatus, headername, ) -from .http.parser import HTTPParser, HTTPProcessingStatus +from .http.parser import HTTPParser # -- @@ -105,7 +106,7 @@ class Connection: # A streaming connection won't be reused isStreaming: bool = False - def close(self): + def close(self) -> Self: """Closes the writer.""" self.writer.close() self.until = None @@ -116,7 +117,7 @@ def isValid(self) -> bool | None: """Tells if the connection is still valid.""" return (time.monotonic() <= self.until) if self.until else None - def touch(self): + def touch(self) -> Self: """Touches the connection, bumping its `until` time.""" self.until = time.monotonic() + self.idle return self @@ -166,7 +167,9 @@ async def Make( class ConnectionPool: """Context-aware pool of connections.""" - All: ClassVar[ContextVar] = ContextVar("httpConnectionsPool") + All: ClassVar[ContextVar[list["ConnectionPool"]]] = ContextVar( + "httpConnectionsPool" + ) @classmethod def Get(cls, *, idle: float | None = None) -> "ConnectionPool": @@ -232,10 +235,9 @@ def Push(cls, *, idle: float | None = None) -> "ConnectionPool": return pool @classmethod - def Pop(cls): + def Pop(cls) -> "ConnectionPool|None": pools = cls.All.get(None) - if pools: - pools.pop().release() + return pools.pop().release() if pools else None def __init__(self, idle: float | None = None): self.connections: dict[ConnectionTarget, list[Connection]] = {} @@ -263,7 +265,7 @@ def put(self, connection: Connection) -> None: as long as it is valid.""" self.connections.setdefault(connection.target, []).append(connection) - def clean(self): + def clean(self) -> Self: """Cleans idle connections by closing them and removing them from available connections.""" to_remove = [] @@ -279,14 +281,15 @@ def clean(self): del self.connections[k] return self - def release(self): + def release(self) -> Self: """Releases all the connections registered""" for l in self.connections.values(): while l: l.pop().close() self.connections.clear() + return self - def pop(self): + def pop(self) -> Self: """Pops this pool from the connection pool context and release all its connections.""" pools = ConnectionPool.All.get(None) @@ -295,10 +298,10 @@ def pop(self): self.release() return self - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, type, value, traceback): + def __exit__(self, type: Any, value: Any, traceback: Any) -> None: """The connection pool automatically cleans when used as a context manager.""" self.clean() @@ -518,7 +521,7 @@ async def Request( @contextmanager -def pooling(idle: float | None = None): +def pooling(idle: float | None = None) -> Iterator[ConnectionPool]: """Creates a context in which connections will be pooled.""" pool = ConnectionPool().Push(idle=idle) try: @@ -529,7 +532,7 @@ def pooling(idle: float | None = None): if __name__ == "__main__": - async def main(): + async def main() -> None: async for atom in HTTPClient.Request( host="google.com", method="GET", diff --git a/src/py/extra/decorators.py b/src/py/extra/decorators.py index fb4d949..0693471 100644 --- a/src/py/extra/decorators.py +++ b/src/py/extra/decorators.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Union, Callable, NamedTuple, TypeVar, Any +from typing import ClassVar, Union, Callable, NamedTuple, TypeVar, Any, cast T = TypeVar("T") @@ -6,7 +6,7 @@ class Transform(NamedTuple): """Represents a transformation to be applied to a request handler""" - transform: Callable + transform: Callable[..., Any] args: tuple[Any, ...] kwargs: dict[str, Any] @@ -29,12 +29,12 @@ class Extra: Annotations: ClassVar[dict[int, dict[str, Any]]] = {} @staticmethod - def Meta(scope: Any, *, strict: bool = False) -> dict: + def Meta(scope: Any, *, strict: bool = False) -> dict[str, Any]: """Returns the dictionary of meta attributes for the given value.""" if isinstance(scope, type): if not hasattr(scope, "__extra__"): setattr(scope, "__extra__", {}) - return getattr(scope, "__extra__") + return cast(dict[str, Any], getattr(scope, "__extra__")) else: if hasattr(scope, "__dict__"): return scope.__dict__ @@ -48,7 +48,7 @@ def Meta(scope: Any, *, strict: bool = False) -> dict: def on( - priority=0, **methods: Union[str, list[str], tuple[str, ...]] + priority: int = 0, **methods: Union[str, list[str], tuple[str, ...]] ) -> Callable[[T], T]: """The @on decorator is one of the main important things you will use within Retro. This decorator allows to wrap an existing method and indicate that @@ -104,7 +104,11 @@ class Expose(NamedTuple): def expose( - priority=0, compress=False, contentType=None, raw=False, **methods + priority: int = 0, + compress: bool = False, + contentType: str | None = None, + raw: bool = False, + **methods: str, ) -> Callable[[T], T]: """The @expose decorator is a variation of the @on decorator. The @expose decorator allows you to _expose_ an existing Python function as a JavaScript @@ -145,12 +149,12 @@ def decorator(function: T) -> T: return decorator -def when(*predicates): +def when(*predicates: Callable[..., bool]) -> Callable[..., T]: """The @when(...) decorate allows to specify that the wrapped method will only be executed when the given predicate (decorated with `@on`) succeeds.""" - def decorator(function): + def decorator(function: T, *args: Any, **kwargs: Any) -> T: v = Extra.Meta(function).setdefault(Extra.WHEN, []) v.extend(predicates) return function @@ -158,11 +162,11 @@ def decorator(function): return decorator -def pre(transform: Callable) -> Callable[[T], T]: +def pre(transform: Callable[..., bool]) -> Callable[..., T]: """Registers the given `transform` as a pre-processing step of the decorated function.""" - def decorator(function: T, *args, **kwargs) -> T: + def decorator(function: T, *args: Any, **kwargs: Any) -> T: v = Extra.Meta(function).setdefault(Extra.PRE, []) v.append(Transform(transform, args, kwargs)) return function @@ -170,11 +174,11 @@ def decorator(function: T, *args, **kwargs) -> T: return decorator -def post(transform) -> Callable[[T], T]: +def post(transform: Callable[..., bool]) -> Callable[[T], T]: """Registers the given `transform` as a post-processing step of the decorated function.""" - def decorator(function: T, *args, **kwargs) -> T: + def decorator(function: T, *args: Any, **kwargs: Any) -> T: v = Extra.Meta(function).setdefault(Extra.POST, []) v.append(Transform(transform, args, kwargs)) return function diff --git a/src/py/extra/handler.py b/src/py/extra/handler.py index 7c7fe73..3a93808 100644 --- a/src/py/extra/handler.py +++ b/src/py/extra/handler.py @@ -108,7 +108,7 @@ def Create( return payload @staticmethod - def AsRequest(event: dict) -> HTTPRequest: + def AsRequest(event: dict[str, Any]) -> HTTPRequest: body: bytes = ( ( b64decode(event["body"].encode()) @@ -237,13 +237,13 @@ def event( uri: str, headers: dict[str, str] | None = None, body: str | bytes | None = None, -) -> dict: +) -> dict[str, Any]: return AWSLambdaEvent.Create(method=method, uri=uri, headers=headers, body=body) def awslambda( handler: Callable[[HTTPRequest], HTTPResponse | Coroutine[Any, HTTPResponse, Any]] -): +) -> Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any]]: def wrapper( event: dict[str, Any], context: dict[str, Any] | None = None ) -> dict[str, Any]: diff --git a/src/py/extra/http/api.py b/src/py/extra/http/api.py index b55b550..be81911 100644 --- a/src/py/extra/http/api.py +++ b/src/py/extra/http/api.py @@ -3,6 +3,7 @@ from pathlib import Path from base64 import b64encode + from .status import HTTP_STATUS from ..utils.json import json from ..utils.files import contentType @@ -41,7 +42,7 @@ def error( content: str | None = None, contentType: str = "text/plain", headers: dict[str, str] | None = None, - ): + ) -> T: message = HTTP_STATUS.get(status, "Server Error") return self.respond( content=message if content is None else content, @@ -54,19 +55,23 @@ def error( def notAuthorized( self, content: str = "Unauthorized", - contentType="text/plain", + contentType: str = "text/plain", *, status: int = 403, - ): + ) -> T: return self.error(status, content=content, contentType=contentType) def notFound( - self, content: str = "Not Found", contentType="text/plain", *, status: int = 404 - ): + self, + content: str = "Not Found", + contentType: str = "text/plain", + *, + status: int = 404, + ) -> T: return self.error(status, content=content, contentType=contentType) - def notModified(self): - pass + def notModified(self) -> None: + raise NotImplementedError def fail( self, @@ -74,12 +79,12 @@ def fail( *, status: int = 500, contentType: str = "text/plain", - ): + ) -> T: return self.respondError( content=content, status=status, contentType=contentType ) - def redirect(self, url: str, permanent: bool = False): + def redirect(self, url: str, permanent: bool = False) -> T: # SEE: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections return self.respondEmpty( status=301 if permanent else 302, headers={"Location": str(url)} @@ -92,7 +97,7 @@ def returns( *, status: int = 200, contentType: str = "application/json", - ): + ) -> T: if isinstance(value, bytes): try: value = value.decode("ascii") @@ -110,12 +115,14 @@ def returns( def respondText( self, content: str | bytes | Iterator[str | bytes], - contentType="text/plain", + contentType: str = "text/plain", status: int = 200, - ): + ) -> T: return self.respond(content=content, contentType=contentType, status=status) - def respondHTML(self, html: str | bytes | Iterator[str | bytes], status: int = 200): + def respondHTML( + self, html: str | bytes | Iterator[str | bytes], status: int = 200 + ) -> T: return self.respond(content=html, contentType="text/html", status=status) def respondFile( @@ -123,7 +130,7 @@ def respondFile( path: Path | str, headers: dict[str, str] | None = None, status: int = 200, - ): + ) -> T: # TODO: We should have a much more detailed file handling, supporting ranges, etags, etc. p: Path = path if isinstance(path, Path) else Path(path) content_type: str = contentType(p) @@ -141,10 +148,10 @@ def respondError( contentType: str = "text/plain", *, status: int = 500, - ): + ) -> T: return self.error(status, content, contentType) - def respondEmpty(self, status, headers: dict[str, str] | None = None): + def respondEmpty(self, status: int, headers: dict[str, str] | None = None) -> T: return self.respond(content=None, status=status, headers=headers) diff --git a/src/py/extra/model.py b/src/py/extra/model.py index c4b4f1c..66cfaa0 100644 --- a/src/py/extra/model.py +++ b/src/py/extra/model.py @@ -151,7 +151,7 @@ def process( else: return self.onRouteNotFound(request) - def mount(self, service: Service, prefix: str | None = None): + def mount(self, service: Service, prefix: str | None = None) -> Service: if service.isMounted: raise RuntimeError( f"Cannot mount service, it is already mounted: {service}" @@ -160,7 +160,7 @@ def mount(self, service: Service, prefix: str | None = None): self.dispatcher.register(handler, prefix or service.prefix) return service - def unmount(self, service: Service): + def unmount(self, service: Service) -> Service: if not service.isMounted: raise RuntimeError( f"Cannot unmount service, it is not already mounted: {service}" @@ -172,7 +172,7 @@ def unmount(self, service: Service): service.app = self return service - def onRouteNotFound(self, request: HTTPRequest): + def onRouteNotFound(self, request: HTTPRequest) -> HTTPResponse: return request.notFound() diff --git a/src/py/extra/routing.py b/src/py/extra/routing.py index b7b974f..d0ee0a3 100644 --- a/src/py/extra/routing.py +++ b/src/py/extra/routing.py @@ -10,6 +10,7 @@ NamedTuple, ClassVar, TypeVar, + Self, cast, ) @@ -27,7 +28,7 @@ T = TypeVar("T") -async def awaited(value: Any): +async def awaited(value: Any) -> Any: if iscoroutine(value): return await value else: @@ -123,7 +124,7 @@ def AddPattern( return res @classmethod - def Parse(cls, expression: str, isStart=True) -> list[TChunk]: + def Parse(cls, expression: str, isStart: bool = True) -> list[TChunk]: """Parses routes expressses as strings where patterns are denoted as `{name}` or `{name:pattern}`""" chunks: list[TChunk] = [] @@ -312,14 +313,16 @@ class Prefix: string.""" @classmethod - def Make(self, values: Iterable[str]): + def Make(self, values: Iterable[str]) -> "Prefix": root = Prefix() for _ in values: root.register(_) root.simplify() return root - def __init__(self, value: str | None = None, parent: Optional["Prefix"] = None): + def __init__( + self, value: str | None = None, parent: Optional["Prefix"] = None + ) -> None: self.value: str | None = value self.parent = parent self.children: dict[str, Prefix] = {} @@ -337,7 +340,7 @@ def simplify(self) -> "Prefix": self.children = dict((k, v.simplify()) for k, v in children.items()) return self - def register(self, text: str): + def register(self, text: str) -> None: """Registers the given `text` in this prefix tree.""" c: str = text[0] if text else "" rest: str = text[1:] if len(text) > 1 else "" @@ -347,7 +350,7 @@ def register(self, text: str): if rest: self.children[c].register(rest) - def iterLines(self, level=0) -> Iterable[str]: + def iterLines(self, level: int = 0) -> Iterable[str]: yield f"{self.value or '┐'}" last_i = len(self.children) - 1 for i, child in enumerate(self.children.values()): @@ -367,10 +370,10 @@ def iterRegExpr(self) -> Iterator[str]: yield from self.children[_].iterRegExpr() yield ")" - def __str__(self): + def __str__(self) -> str: return "\n".join(self.iterLines()) - def __repr__(self): + def __repr__(self) -> str: return f"'{self.value or '⦰'}'→({', '.join(repr(_) for _ in self.children.values())})" @@ -387,12 +390,17 @@ class Handler: a request.""" @classmethod - def Has(cls, value): + def Has(cls, value: Any) -> bool: return hasattr(value, Extra.ON) @classmethod def Attr( - cls, value: Any, key: str, extra: dict | None = None, *, merge: bool = False + cls, + value: Any, + key: str, + extra: dict[str, Any] | None = None, + *, + merge: bool = False, ) -> Any: extra_value = extra[key] if extra and key in extra else None sid = id(value) @@ -425,7 +433,7 @@ def Attr( # FIXME: Handlers should compose transforms and predicate, right now it's # passed as attributes, but it should not really be a stack of transforms. @classmethod - def Get(cls, value, extra: dict | None = None): + def Get(cls, value: Any, extra: dict[str, Any] | None = None) -> Any | None: return ( Handler( functor=value, @@ -443,14 +451,14 @@ def Get(cls, value, extra: dict | None = None): def __init__( self, # TODO: Refine type - functor: Callable, + functor: Callable[..., Any], methods: list[tuple[str, str]], priority: int = 0, expose: Expose | None = None, contentType: str | None = None, pre: list[Transform] | None = None, post: list[Transform] | None = None, - ): + ) -> None: self.functor = functor # This extracts and normalizes the methods # NOTE: This may have been done at the decoartor level @@ -530,7 +538,7 @@ async def postprocess(request, response, transforms): t.transform(request, response, *t.args, **t.kwargs) return response - def __repr__(self): + def __repr__(self) -> str: methods = " ".join( f'({k} {" ".join(repr(_) for _ in v)})' for k, v in self.methods.items() ) @@ -574,7 +582,7 @@ def register(self, handler: Handler, prefix: str | None = None) -> "Dispatcher": self.isPrepared = False return self - def prepare(self): + def prepare(self) -> Self: """Prepares the dispatcher, which optimizes the prefix tree for faster matching.""" res = {} for method, routes in self.routes.items(): diff --git a/src/py/extra/server.py b/src/py/extra/server.py index 08d6a2c..7f6d64f 100644 --- a/src/py/extra/server.py +++ b/src/py/extra/server.py @@ -13,8 +13,9 @@ HTTPBodyBlob, HTTPResponseFile, HTTPBodyReader, + HTTPProcessingStatus, ) -from .http.parser import HTTPParser, HTTPProcessingStatus +from .http.parser import HTTPParser from .config import HOST, PORT @@ -51,13 +52,18 @@ class AIOSocketBodyReader(HTTPBodyReader): __slots__ = ["socket", "loop", "buffer"] - def __init__(self, socket, loop, size: int = 64_000): + def __init__( + self, + socket: "socket.socket", + loop: asyncio.AbstractEventLoop, + size: int = 64_000, + ) -> None: self.socket = socket self.loop = loop - async def read(self, timeout: float = 1.0) -> bytes | None: + async def read(self, timeout: float = 1.0, size: int = 64_000) -> bytes | None: return await asyncio.wait_for( - self.loop.sock_recv(self.socket), + self.loop.sock_recv(self.socket, size), timeout=timeout, ) @@ -76,7 +82,7 @@ async def OnRequest( *, loop: asyncio.AbstractEventLoop, options: ServerOptions, - ): + ) -> None: """Asynchronous worker, processing a socket in the context of an application.""" try: @@ -278,7 +284,7 @@ async def Serve( cls, app: Application, options: ServerOptions = ServerOptions(), - ): + ) -> None: server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((options.host, options.port)) @@ -288,7 +294,7 @@ async def Serve( # This is what we need to use it with asyncio server.setblocking(False) - tasks: set[asyncio.Task] = set() + tasks: set[asyncio.Task[None]] = set() try: loop = asyncio.get_running_loop() except RuntimeError: @@ -307,13 +313,17 @@ async def Serve( if options.condition and not options.condition(): break try: - client, _ = ( + res = ( await asyncio.wait_for( loop.sock_accept(server), timeout=options.timeout ) if options.timeout else loop.sock_accept(server) ) + if res is None: + continue + else: + client = res[0] # NOTE: Should do something with the tasks task = loop.create_task( cls.OnRequest(app, client, loop=loop, options=options) @@ -342,9 +352,9 @@ def run( host: str = HOST, port: int = PORT, backlog: int = 10_000, - condition: Callable | None = None, + condition: Callable[[], bool] | None = None, timeout: float = 10.0, -): +) -> None: unlimit(LimitType.Files) options = ServerOptions( host=host, port=port, backlog=backlog, condition=condition, timeout=timeout diff --git a/src/py/extra/services/files.py b/src/py/extra/services/files.py index b8cbe92..e5bb7eb 100644 --- a/src/py/extra/services/files.py +++ b/src/py/extra/services/files.py @@ -4,8 +4,9 @@ from ..model import Service from ..http.model import HTTPRequest, HTTPResponse from ..features.cors import cors -from ..utils.htmpl import H, html -import os, html +from ..utils.htmpl import Node, H, html +from html import escape +import os FILE_CSS: str = """ @@ -48,7 +49,9 @@ def __init__(self, root: Path | None = None): self.canRead: Callable[[HTTPRequest, Path], bool] = lambda r, p: True self.canDelete: Callable[[HTTPRequest, Path], bool] = lambda r, p: True - def renderPath(self, request: HTTPRequest, path: str, localPath: Path): + def renderPath( + self, request: HTTPRequest, path: str, localPath: Path + ) -> HTTPResponse: path = path.strip("/") if localPath.is_dir(): return self.renderDir(request, path, localPath) @@ -64,18 +67,18 @@ def renderDir( parent = None if path.endswith("/"): path = path[:-1] - files: list[str] = [] - dirs: list[str] = [] - # TODO: We may want to have a strict mode to prevent resolving symlinks + + files: list[Node] = [] + dirs: list[Node] = [] if localPath.is_dir(): for p in sorted(localPath.iterdir()): # We really want the href to be absolute href = os.path.join("/", self.PREFIX or "/", path, p.name) if p.is_dir(): - dirs.append(H.li(H.a(f"{html.escape(p.name)}/", href=href))) + dirs.append(H.li(H.a(f"{escape(p.name)}/", href=href))) else: - files.append(H.li(H.a(html.escape(p.name), href=href))) - nodes = [] + files.append(H.li(H.a(p.name, href=href))) + nodes: list[Node] = [] if parent is not None: dirs.insert(0, H.li(H.a("..", href=f"/{parent}"))) @@ -97,7 +100,7 @@ def renderDir( prefix = self.PREFIX or "/" if not prefix.startswith("/"): prefix = f"/{prefix}" - breadcrumbs = [H.a("/", href=prefix)] + breadcrumbs: list[Node | str] = [H.a("/", href=prefix)] for i, bp in enumerate(path_chunks[:-1]): breadcrumbs.append( H.a(bp, href=os.path.join(prefix, *path_chunks[: i + 1])) @@ -132,7 +135,7 @@ def renderDir( # def favicon(self, request: HTTPRequest, path: str): @on(INFO=("/", "/{path:any}")) - def info(self, request: HTTPRequest, path: str): + def info(self, request: HTTPRequest, path: str) -> HTTPResponse: local_path = self.resolvePath(path) if not (local_path and self.canRead(request, local_path)): return request.notAuthorized(f"Not authorized to access path: {path}") @@ -141,7 +144,7 @@ def info(self, request: HTTPRequest, path: str): @cors @on(HEAD=("/", "/{path:any}")) - def head(self, request: HTTPRequest, path: str): + def head(self, request: HTTPRequest, path: str) -> HTTPResponse: local_path = self.resolvePath(path) if not (local_path and self.canRead(request, local_path)): return request.notAuthorized(f"Not authorized to access path: {path}") @@ -150,7 +153,7 @@ def head(self, request: HTTPRequest, path: str): @cors @on(GET=("/", "/{path:any}")) - def read(self, request: HTTPRequest, path: str = "."): + def read(self, request: HTTPRequest, path: str = ".") -> HTTPResponse: local_path = self.resolvePath(path) if not (local_path and self.canRead(request, local_path)): return request.notAuthorized(f"Not authorized to access path: {path}") @@ -160,7 +163,7 @@ def read(self, request: HTTPRequest, path: str = "."): return self.renderPath(request, path, local_path) @on(PUT_PATCH="/{path:any}") - def write(self, request: HTTPRequest, path: str = "."): + def write(self, request: HTTPRequest, path: str = ".") -> HTTPResponse: local_path = self.resolvePath(path) if not (local_path and self.canWrite(request, local_path)): return request.notAuthorized(f"Not authoried to write to path: {path}") @@ -174,7 +177,7 @@ def write(self, request: HTTPRequest, path: str = "."): return request.returns(True) @on(DELETE="/{path:any}") - def delete(self, request, path): + def delete(self, request: HTTPRequest, path: str) -> HTTPResponse: local_path = self.resolvePath(path) if not (local_path and self.canWrite(request, local_path)): return request.notAuthorized(f"Not authorized to delete path: {path}") @@ -185,14 +188,27 @@ def delete(self, request, path): return request.returns(True) else: return request.returns(False) + else: + return request.returns(False) def resolvePath(self, path: Union[str, Path]) -> Path | None: - path = self.root.joinpath(path).absolute() - if path.parts[: len(parts := self.root.parts)] == parts: - return path - else: + has_slash = isinstance(path, str) and path.endswith("/") + local_path = self.root.joinpath(path).absolute() + if not local_path.parts[: len(parts := self.root.parts)] == parts: return None - return path if path.parts[: len(parts := self.root.parts)] == parts else None + if local_path.is_dir(): + index_path = local_path / "index.html" + if not has_slash and index_path.exists(): + return index_path + else: + return local_path + else: + if not local_path.exists() and not local_path.suffix: + for suffix in [".html", ".htm", ".txt", ".md"]: + html_path = local_path.with_suffix(suffix) + if html_path.exists(): + return html_path + return local_path # EOF diff --git a/src/py/extra/utils/htmpl.py b/src/py/extra/utils/htmpl.py index f69ff9c..27af14d 100644 --- a/src/py/extra/utils/htmpl.py +++ b/src/py/extra/utils/htmpl.py @@ -5,6 +5,7 @@ Iterator, Union, Callable, + Self, cast, ) from mypy_extensions import KwArg, VarArg @@ -56,8 +57,10 @@ def __init__( def iterHTML(self) -> Iterator[str]: yield from self.iterXML(html=True) - def iterXML(self, html=False) -> Iterator[str]: - if self.name == "#text": + def iterXML(self, html: bool = False) -> Iterator[str]: + if self.name == "#raw": + yield str(self.attributes.get("#value") or "") + elif self.name == "#text": yield escape(str(self.attributes.get("#value") or "")) elif self.name == "--": yield "