]> xn--ix-yja.es Git - alex.git/commitdiff
Refactor in modules, add gemtext parser
authoralex <alex@pdp7.net>
Sun, 17 Sep 2023 15:39:54 +0000 (17:39 +0200)
committeralex <alex@pdp7.net>
Sun, 17 Sep 2023 15:39:54 +0000 (17:39 +0200)
blog_experiment/blog.py [deleted file]
blog_experiment/blog/__init__.py [new file with mode: 0644]
blog_experiment/blog/__main__.py [new file with mode: 0644]
blog_experiment/blog/blog_pages.py [new file with mode: 0644]
blog_experiment/blog/gemtext.py [new file with mode: 0644]
blog_experiment/blog/html.py [new file with mode: 0644]
blog_experiment/blog/page.py [new file with mode: 0644]
blog_experiment/poetry.lock
blog_experiment/pyproject.toml

diff --git a/blog_experiment/blog.py b/blog_experiment/blog.py
deleted file mode 100644 (file)
index 54013cc..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-import datetime
-import itertools
-import logging
-import pathlib
-import re
-import subprocess
-import textwrap
-
-import htmlgenerator as h
-
-import bicephalus
-from bicephalus import main as bicephalus_main
-from bicephalus import otel
-from bicephalus import ssl
-
-
-def tidy(s):
-    p = subprocess.run(
-        ["tidy", "--indent", "yes", "-q", "-wrap", "160"],
-        input=s,
-        stdout=subprocess.PIPE,
-        encoding="UTF8",
-    )
-    return p.stdout
-
-
-def html_template(*content):
-    return tidy(
-        h.render(
-            h.HTML(
-                h.HEAD(h.TITLE("El blog es mío")),
-                h.BODY(
-                    h.H1("El blog es mío"),
-                    h.H2("Hay otros como él, pero este es el mío"),
-                    *content,
-                ),
-            ),
-            {},
-        )
-    )
-
-
-class BasePage:
-    def __init__(self, request):
-        self.request = request
-
-    def response(self):
-        if self.request.proto == bicephalus.Proto.GEMINI:
-            status, content_type, content = self.get_gemini_content()
-        elif self.request.proto == bicephalus.Proto.HTTP:
-            status, content_type, content = self.get_http_content()
-        else:
-            assert False, f"unknown protocol {self.request.proto}"
-
-        return bicephalus.Response(
-            content=content.encode("utf8"),
-            content_type=content_type,
-            status=bicephalus.Status.OK,
-        )
-
-
-class Entry:
-    def __init__(self, path: pathlib.Path):
-        assert path.is_relative_to(pathlib.Path("content")), f"bad path {path}"
-        self.path = path
-        self.content = path.read_text()
-
-    @property
-    def title(self):
-        return self.content.splitlines()[0][2:]
-
-    @property
-    def posted(self):
-        return datetime.date.fromisoformat(self.content.splitlines()[1])
-
-    @property
-    def uri(self):
-        return f"/{self.path.parts[1]}/{self.path.parts[2]}/{self.path.stem}/"
-
-
-class Root(BasePage):
-    def entries(self):
-        entries = map(Entry, pathlib.Path("content").glob("*/*/*.gmi"))
-        return sorted(entries, key=lambda e: e.posted, reverse=True)
-
-    def get_gemini_content(self):
-        posts = "\n".join([f"=> {e.uri} {e.posted} {e.title}" for e in self.entries()])
-        content = (
-            textwrap.dedent(
-                """\
-                # El blog es mío
-
-                ## Hay otros como él, pero este es el mío
-
-                ____
-                """
-            )
-            + posts
-        )
-        return bicephalus.Status.OK, "text/gemini", content
-
-    def get_http_content(self):
-        posts = [
-            (h.H3(h.A(f"{e.title} ({e.posted})", href=e.uri))) for e in self.entries()
-        ]
-        return (
-            bicephalus.Status.OK,
-            "text/html",
-            html_template(*itertools.chain(posts)),
-        )
-
-
-class EntryPage(BasePage):
-    def __init__(self, request, path):
-        super().__init__(request)
-        self.path = path
-        self.entry = Entry(path)
-
-    def get_gemini_content(self):
-        return bicephalus.Status.OK, "text/gemini", self.entry.content
-
-    def get_http_content(self):
-        return (
-            bicephalus.Status.OK,
-            "text/html",
-            html_template(
-                h.PRE(self.entry.content),
-            ),
-        )
-
-
-class NotFound(BasePage):
-    def get_gemini_content(self):
-        # TODO: does not work!
-        return (
-            bicephalus.Status.NOT_FOUND,
-            "text/gemini",
-            f"{self.request.path} not found",
-        )
-
-    def get_http_content(self):
-        return (
-            bicephalus.Status.NOT_FOUND,
-            "text/html",
-            f"{self.request.path} not found",
-        )
-
-
-def handler(request: bicephalus.Request) -> bicephalus.Response:
-    if request.path == "/":
-        return Root(request).response()
-    if re.match(r"/\d{4}/\d{2}/.*/", request.path):
-        blog_file = pathlib.Path("content") / (request.path[1:-1] + ".gmi")
-        if blog_file.exists():
-            return EntryPage(request, blog_file).response()
-    return NotFound(request).response()
-
-
-def main():
-    otel.configure_logging(logging.INFO)
-    with ssl.temporary_ssl_context("localhost") as ssl_context:
-        bicephalus_main.main(handler, ssl_context, 8000)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/blog_experiment/blog/__init__.py b/blog_experiment/blog/__init__.py
new file mode 100644 (file)
index 0000000..4b1e0ba
--- /dev/null
@@ -0,0 +1,16 @@
+import pathlib
+import re
+
+import bicephalus
+
+from blog import blog_pages, page
+
+
+def handler(request: bicephalus.Request) -> bicephalus.Response:
+    if request.path == "/":
+        return blog_pages.Root(request).response()
+    if re.match(r"/\d{4}/\d{2}/.*/", request.path):
+        blog_file = pathlib.Path("content") / (request.path[1:-1] + ".gmi")
+        if blog_file.exists():
+            return blog_pages.EntryPage(request, blog_file).response()
+    return page.NotFound(request).response()
diff --git a/blog_experiment/blog/__main__.py b/blog_experiment/blog/__main__.py
new file mode 100644 (file)
index 0000000..b936500
--- /dev/null
@@ -0,0 +1,17 @@
+import logging
+
+from bicephalus import main as bicephalus_main
+from bicephalus import otel
+from bicephalus import ssl
+
+import blog
+
+
+def main():
+    otel.configure_logging(logging.INFO)
+    with ssl.temporary_ssl_context("localhost") as ssl_context:
+        bicephalus_main.main(blog.handler, ssl_context, 8000)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/blog_experiment/blog/blog_pages.py b/blog_experiment/blog/blog_pages.py
new file mode 100644 (file)
index 0000000..7808c16
--- /dev/null
@@ -0,0 +1,80 @@
+import datetime
+import itertools
+import pathlib
+import textwrap
+
+import bicephalus
+
+import htmlgenerator as h
+
+from blog import html, page
+
+
+class Entry:
+    def __init__(self, path: pathlib.Path):
+        assert path.is_relative_to(pathlib.Path("content")), f"bad path {path}"
+        self.path = path
+        self.content = path.read_text()
+
+    @property
+    def title(self):
+        return self.content.splitlines()[0][2:]
+
+    @property
+    def posted(self):
+        return datetime.date.fromisoformat(self.content.splitlines()[1])
+
+    @property
+    def uri(self):
+        return f"/{self.path.parts[1]}/{self.path.parts[2]}/{self.path.stem}/"
+
+
+class Root(page.BasePage):
+    def entries(self):
+        entries = map(Entry, pathlib.Path("content").glob("*/*/*.gmi"))
+        return sorted(entries, key=lambda e: e.posted, reverse=True)
+
+    def get_gemini_content(self):
+        posts = "\n".join([f"=> {e.uri} {e.posted} {e.title}" for e in self.entries()])
+        content = (
+            textwrap.dedent(
+                """\
+                # El blog es mío
+
+                ## Hay otros como él, pero este es el mío
+
+                ____
+                """
+            )
+            + posts
+        )
+        return bicephalus.Status.OK, "text/gemini", content
+
+    def get_http_content(self):
+        posts = [
+            (h.H3(h.A(f"{e.title} ({e.posted})", href=e.uri))) for e in self.entries()
+        ]
+        return (
+            bicephalus.Status.OK,
+            "text/html",
+            html.html_template(*itertools.chain(posts)),
+        )
+
+
+class EntryPage(page.BasePage):
+    def __init__(self, request, path):
+        super().__init__(request)
+        self.path = path
+        self.entry = Entry(path)
+
+    def get_gemini_content(self):
+        return bicephalus.Status.OK, "text/gemini", self.entry.content
+
+    def get_http_content(self):
+        return (
+            bicephalus.Status.OK,
+            "text/html",
+            html.html_template(
+                h.PRE(self.entry.content),
+            ),
+        )
diff --git a/blog_experiment/blog/gemtext.py b/blog_experiment/blog/gemtext.py
new file mode 100644 (file)
index 0000000..66298e3
--- /dev/null
@@ -0,0 +1,223 @@
+import dataclasses
+import re
+import typing
+
+
+def parse(s):
+    """
+    >>> parse('''# Header 1
+    ... 
+    ... ## Header 2
+    ... 
+    ... ### Header 3
+    ... 
+    ... * List 1
+    ... * List 2
+    ... 
+    ... > First line quote.
+    ... > Second line of quote.
+    ... 
+    ... ```
+    ... Fenced
+    ... Lines
+    ... ```
+    ... 
+    ... Paragraph.
+    ... 
+    ... Another paragraph.
+    ... ''')
+    [Header(level=1, text='Header 1'),
+     Line(text=''),
+     Header(level=2, text='Header 2'),
+     Line(text=''),
+     Header(level=3, text='Header 3'),
+     Line(text=''),
+     List(items=[ListItem(text='List 1'),
+                 ListItem(text='List 2')]),
+     Line(text=''),
+     BlockQuote(lines=[BlockQuoteLine(text='First line quote.'),
+                       BlockQuoteLine(text='Second line of quote.')]),
+     Line(text=''),
+     Pre(content='Fenced\\nLines\\n'),
+     Line(text=''),
+     Line(text='Paragraph.'),
+     Line(text=''),
+     Line(text='Another paragraph.')]
+    """
+
+    lines = s.splitlines()
+
+    i = 0
+    gem = []
+
+    while i < len(lines):
+        line = parse_line(lines[i])
+
+        if isinstance(line, Link):
+            gem.append(line)
+            i = i + 1
+            continue
+
+        if isinstance(line, Header):
+            gem.append(line)
+            i = i + 1
+            continue
+
+        if isinstance(line, ListItem):
+            items = []
+            while i < len(lines) and isinstance(parse_line(lines[i]), ListItem):
+                items.append(parse_line(lines[i]))
+                i = i + 1
+            gem.append(List(items))
+            continue
+
+        if isinstance(line, BlockQuoteLine):
+            quotes = []
+            while i < len(lines) and isinstance(parse_line(lines[i]), BlockQuoteLine):
+                quotes.append(parse_line(lines[i]))
+                i = i + 1
+            gem.append(BlockQuote(quotes))
+            continue
+
+        if isinstance(line, PreFence):
+            content = ""
+            i = i + 1
+            while i < len(lines) and not isinstance(parse_line(lines[i]), PreFence):
+                content += lines[i]
+                content += "\n"
+                i = i + 1
+            gem.append(Pre(content))
+            i = i + 1
+            continue
+
+        gem.append(line)
+        i = i + 1
+
+    return gem
+
+
+def parse_line(l):
+    if Link.is_link(l):
+        return Link(l)
+    if Header.is_header(l):
+        return Header(l)
+    if ListItem.is_list_item(l):
+        return ListItem(l)
+    if BlockQuoteLine.is_block_quote_line(l):
+        return BlockQuoteLine(l)
+    if PreFence.is_pre_fence(l):
+        return PreFence()
+    return Line(l)
+
+
+@dataclasses.dataclass
+class Link:
+    """
+    >>> Link("=> http://example.com")
+    Link(url='http://example.com', text=None)
+
+    >>> Link("=> http://example.com Example text")
+    Link(url='http://example.com', text='Example text')
+    """
+
+    url: str
+    text: typing.Optional[str]
+
+    def __init__(self, line: str):
+        assert Link.is_link(line)
+        parts = line.split(None, 2)
+        self.url = parts[1]
+        self.text = parts[2] if len(parts) > 2 else None
+
+    @staticmethod
+    def is_link(line: str):
+        return line.startswith("=>")
+
+@dataclasses.dataclass
+class Header:
+    """
+    >>> Header("# Level one")
+    Header(level=1, text='Level one')
+
+    >>> Header("## Level two")
+    Header(level=2, text='Level two')
+
+    >>> Header("### Level three")
+    Header(level=3, text='Level three')
+    """
+
+    level: int
+    text: str
+
+    def __init__(self, line: str):
+        assert Header.is_header(line)
+        hashes, self.text = line.split(None, 1)
+        self.level = len(hashes)
+
+    @staticmethod
+    def is_header(line: str):
+        return re.match("#{1,3} .*", line)
+
+@dataclasses.dataclass
+class ListItem:
+    """
+    >>> ListItem("* foo")
+    ListItem(text='foo')
+    """
+
+    text: str
+
+    def __init__(self, line: str):
+        assert ListItem.is_list_item(line)
+        self.text = line[2:]
+
+    @staticmethod
+    def is_list_item(line: str):
+        return line.startswith("* ")
+
+
+@dataclasses.dataclass
+class BlockQuoteLine:
+    """
+    >>> BlockQuoteLine("> foo")
+    BlockQuoteLine(text='foo')
+
+    >>> BlockQuoteLine(">foo")
+    BlockQuoteLine(text='foo')
+    """
+
+    text: str
+
+    def __init__(self, line: str):
+        assert BlockQuoteLine.is_block_quote_line(line)
+        self.text = line[2:] if line.startswith("> ") else line[1:]
+
+    @staticmethod
+    def is_block_quote_line(line: str):
+        return line.startswith(">")
+
+
+class PreFence:
+    @staticmethod
+    def is_pre_fence(line: str):
+        return line == "```"
+
+
+@dataclasses.dataclass
+class Line:
+    text: str
+
+
+@dataclasses.dataclass
+class List:
+    items: typing.List[ListItem]
+
+
+@dataclasses.dataclass
+class BlockQuote:
+    lines: typing.List[BlockQuoteLine]
+
+
+@dataclasses.dataclass
+class Pre:
+    content: str
diff --git a/blog_experiment/blog/html.py b/blog_experiment/blog/html.py
new file mode 100644 (file)
index 0000000..7293d39
--- /dev/null
@@ -0,0 +1,29 @@
+import subprocess
+
+import htmlgenerator as h
+
+
+def tidy(s):
+    p = subprocess.run(
+        ["tidy", "--indent", "yes", "-q", "-wrap", "160"],
+        input=s,
+        stdout=subprocess.PIPE,
+        encoding="UTF8",
+    )
+    return p.stdout
+
+
+def html_template(*content):
+    return tidy(
+        h.render(
+            h.HTML(
+                h.HEAD(h.TITLE("El blog es mío")),
+                h.BODY(
+                    h.H1("El blog es mío"),
+                    h.H2("Hay otros como él, pero este es el mío"),
+                    *content,
+                ),
+            ),
+            {},
+        )
+    )
diff --git a/blog_experiment/blog/page.py b/blog_experiment/blog/page.py
new file mode 100644 (file)
index 0000000..fcc4841
--- /dev/null
@@ -0,0 +1,37 @@
+import bicephalus
+
+
+class BasePage:
+    def __init__(self, request):
+        self.request = request
+
+    def response(self):
+        if self.request.proto == bicephalus.Proto.GEMINI:
+            status, content_type, content = self.get_gemini_content()
+        elif self.request.proto == bicephalus.Proto.HTTP:
+            status, content_type, content = self.get_http_content()
+        else:
+            assert False, f"unknown protocol {self.request.proto}"
+
+        return bicephalus.Response(
+            content=content.encode("utf8"),
+            content_type=content_type,
+            status=bicephalus.Status.OK,
+        )
+
+
+class NotFound(BasePage):
+    def get_gemini_content(self):
+        # TODO: does not work!
+        return (
+            bicephalus.Status.NOT_FOUND,
+            "text/gemini",
+            f"{self.request.path} not found",
+        )
+
+    def get_http_content(self):
+        return (
+            bicephalus.Status.NOT_FOUND,
+            "text/html",
+            f"{self.request.path} not found",
+        )
index f8e02b76ccddffd9525af12c4c34707be6aa88fc..a33e1d917c89c7d18558bf4c226724254c934ac1 100644 (file)
@@ -124,6 +124,36 @@ files = [
 [package.dependencies]
 frozenlist = ">=1.1.0"
 
+[[package]]
+name = "appnope"
+version = "0.1.3"
+description = "Disable App Nap on macOS >= 10.9"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"},
+    {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"},
+]
+
+[[package]]
+name = "asttokens"
+version = "2.4.0"
+description = "Annotate AST trees with source code positions"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "asttokens-2.4.0-py2.py3-none-any.whl", hash = "sha256:cf8fc9e61a86461aa9fb161a14a0841a03c405fa829ac6b202670b3495d2ce69"},
+    {file = "asttokens-2.4.0.tar.gz", hash = "sha256:2e0171b991b2c959acc6c49318049236844a5da1d65ba2672c4880c1c894834e"},
+]
+
+[package.dependencies]
+six = ">=1.12.0"
+
+[package.extras]
+test = ["astroid", "pytest"]
+
 [[package]]
 name = "async-timeout"
 version = "4.0.3"
@@ -155,6 +185,18 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-
 tests = ["attrs[tests-no-zope]", "zope-interface"]
 tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
 
+[[package]]
+name = "backcall"
+version = "0.2.0"
+description = "Specifications for callback functions passed in to an API"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
+    {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
+]
+
 [[package]]
 name = "bicephalus"
 version = "0.1.0"
@@ -259,6 +301,30 @@ files = [
     {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"},
 ]
 
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "decorator"
+version = "5.1.1"
+description = "Decorators for Humans"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
+    {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
+]
+
 [[package]]
 name = "deprecated"
 version = "1.2.14"
@@ -277,6 +343,36 @@ wrapt = ">=1.10,<2"
 [package.extras]
 dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"]
 
+[[package]]
+name = "exceptiongroup"
+version = "1.1.3"
+description = "Backport of PEP 654 (exception groups)"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"},
+    {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "executing"
+version = "1.2.0"
+description = "Get the currently executing AST node of a frame, and other information"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"},
+    {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"},
+]
+
+[package.extras]
+tests = ["asttokens", "littleutils", "pytest", "rich"]
+
 [[package]]
 name = "frozenlist"
 version = "1.4.0"
@@ -395,6 +491,94 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker
 perf = ["ipython"]
 testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
 
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "ipython"
+version = "8.15.0"
+description = "IPython: Productive Interactive Computing"
+category = "dev"
+optional = false
+python-versions = ">=3.9"
+files = [
+    {file = "ipython-8.15.0-py3-none-any.whl", hash = "sha256:45a2c3a529296870a97b7de34eda4a31bee16bc7bf954e07d39abe49caf8f887"},
+    {file = "ipython-8.15.0.tar.gz", hash = "sha256:2baeb5be6949eeebf532150f81746f8333e2ccce02de1c7eedde3f23ed5e9f1e"},
+]
+
+[package.dependencies]
+appnope = {version = "*", markers = "sys_platform == \"darwin\""}
+backcall = "*"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+decorator = "*"
+exceptiongroup = {version = "*", markers = "python_version < \"3.11\""}
+jedi = ">=0.16"
+matplotlib-inline = "*"
+pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""}
+pickleshare = "*"
+prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0"
+pygments = ">=2.4.0"
+stack-data = "*"
+traitlets = ">=5"
+typing-extensions = {version = "*", markers = "python_version < \"3.10\""}
+
+[package.extras]
+all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"]
+black = ["black"]
+doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"]
+kernel = ["ipykernel"]
+nbconvert = ["nbconvert"]
+nbformat = ["nbformat"]
+notebook = ["ipywidgets", "notebook"]
+parallel = ["ipyparallel"]
+qtconsole = ["qtconsole"]
+test = ["pytest (<7.1)", "pytest-asyncio", "testpath"]
+test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"]
+
+[[package]]
+name = "jedi"
+version = "0.19.0"
+description = "An autocompletion tool for Python that can be used for text editors."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "jedi-0.19.0-py2.py3-none-any.whl", hash = "sha256:cb8ce23fbccff0025e9386b5cf85e892f94c9b822378f8da49970471335ac64e"},
+    {file = "jedi-0.19.0.tar.gz", hash = "sha256:bcf9894f1753969cbac8022a8c2eaee06bfa3724e4192470aaffe7eb6272b0c4"},
+]
+
+[package.dependencies]
+parso = ">=0.8.3,<0.9.0"
+
+[package.extras]
+docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"]
+qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
+testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.1.6"
+description = "Inline Matplotlib backend for Jupyter"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"},
+    {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"},
+]
+
+[package.dependencies]
+traitlets = "*"
+
 [[package]]
 name = "multidict"
 version = "6.0.4"
@@ -524,6 +708,217 @@ files = [
     {file = "opentelemetry_semantic_conventions-0.41b0.tar.gz", hash = "sha256:0ce5b040b8a3fc816ea5879a743b3d6fe5db61f6485e4def94c6ee4d402e1eb7"},
 ]
 
+[[package]]
+name = "packaging"
+version = "23.1"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
+    {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
+]
+
+[[package]]
+name = "parso"
+version = "0.8.3"
+description = "A Python Parser"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"},
+    {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"},
+]
+
+[package.extras]
+qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
+testing = ["docopt", "pytest (<6.0.0)"]
+
+[[package]]
+name = "pexpect"
+version = "4.8.0"
+description = "Pexpect allows easy control of interactive console applications."
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
+    {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
+]
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "pickleshare"
+version = "0.7.5"
+description = "Tiny 'shelve'-like database with concurrency support"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
+    {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.3.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
+    {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.39"
+description = "Library for building powerful interactive command lines in Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+    {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"},
+    {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"},
+]
+
+[package.dependencies]
+wcwidth = "*"
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+    {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.2"
+description = "Safely evaluate AST nodes without side effects"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"},
+    {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"},
+]
+
+[package.extras]
+tests = ["pytest"]
+
+[[package]]
+name = "pygments"
+version = "2.16.1"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"},
+    {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+
+[[package]]
+name = "pytest"
+version = "7.4.2"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"},
+    {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "stack-data"
+version = "0.6.2"
+description = "Extract data from python stack frames and tracebacks for informative displays"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "stack_data-0.6.2-py3-none-any.whl", hash = "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8"},
+    {file = "stack_data-0.6.2.tar.gz", hash = "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815"},
+]
+
+[package.dependencies]
+asttokens = ">=2.1.0"
+executing = ">=1.2.0"
+pure-eval = "*"
+
+[package.extras]
+tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "traitlets"
+version = "5.10.0"
+description = "Traitlets Python configuration system"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "traitlets-5.10.0-py3-none-any.whl", hash = "sha256:417745a96681fbb358e723d5346a547521f36e9bd0d50ba7ab368fff5d67aa54"},
+    {file = "traitlets-5.10.0.tar.gz", hash = "sha256:f584ea209240466e66e91f3c81aa7d004ba4cf794990b0c775938a1544217cd1"},
+]
+
+[package.extras]
+docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
+test = ["argcomplete (>=3.0.3)", "mypy (>=1.5.1)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"]
+
 [[package]]
 name = "typing-extensions"
 version = "4.7.1"
@@ -536,6 +931,18 @@ files = [
     {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"},
 ]
 
+[[package]]
+name = "wcwidth"
+version = "0.2.6"
+description = "Measures the displayed width of unicode strings in a terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+files = [
+    {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"},
+    {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"},
+]
+
 [[package]]
 name = "wrapt"
 version = "1.15.0"
@@ -728,4 +1135,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.9"
-content-hash = "99c024f4a65e09366dcdaea032ad082d8975f2eec41cd3d82873a53210103340"
+content-hash = "bb42f53ab222fa881b0b2019c0de76190c1c33895444363709aedda9a9bc9a3e"
index d02743eb04c6998e7d9479f1a46a8edbc75c8971..faaf65e9cef296517b011068c8b74e8d5211ebd5 100644 (file)
@@ -1,17 +1,23 @@
 [tool.poetry]
-name = "blog-experiment"
+name = "blog"
 version = "0.1.0"
 description = ""
 authors = ["alex <alex@pdp7.net>"]
-readme = "README.md"
-packages = [{include = "blog_experiment"}]
+packages = [{include = "blog"}]
 
 [tool.poetry.dependencies]
 python = "^3.9"
 bicephalus = {path = "/home/alex/git/bicephalus", develop = true}
 htmlgenerator = "^1.2.28"
 
+[tool.poetry.group.dev.dependencies]
+pytest = "^7.4.2"
+ipython = "^8.15.0"
 
 [build-system]
 requires = ["poetry-core"]
 build-backend = "poetry.core.masonry.api"
+
+[tool.pytest.ini_options]
+addopts = "--doctest-modules"
+doctest_optionflags = "NORMALIZE_WHITESPACE"