Merge branch 'mypy'

This commit is contained in:
Bastian Venthur
2022-09-01 18:56:53 +02:00
17 changed files with 397 additions and 269 deletions

View File

@@ -36,3 +36,7 @@ jobs:
- name: Run linter - name: Run linter
run: | run: |
make lint make lint
- name: Run mypy
run: |
make mypy

View File

@@ -2,6 +2,7 @@
## unreleased ## unreleased
* added type hints and mypy --strict to test suite
* improved default template * improved default template
* updated dependencies: * updated dependencies:
* markdown 3.4.1 * markdown 3.4.1

View File

@@ -14,7 +14,7 @@ endif
.PHONY: all .PHONY: all
all: lint test all: lint mypy test
$(VENV): requirements.txt requirements-dev.txt setup.py $(VENV): requirements.txt requirements-dev.txt setup.py
$(PY) -m venv $(VENV) $(PY) -m venv $(VENV)
@@ -27,6 +27,10 @@ $(VENV): requirements.txt requirements-dev.txt setup.py
test: $(VENV) test: $(VENV)
$(BIN)/pytest $(BIN)/pytest
.PHONY: mypy
mypy: $(VENV)
$(BIN)/mypy
.PHONY: lint .PHONY: lint
lint: $(VENV) lint: $(VENV)
$(BIN)/flake8 $(BIN)/flake8

View File

@@ -1 +1 @@
from blag.version import __VERSION__ # noqa from blag.version import __VERSION__ as __VERSION__ # noqa

View File

@@ -4,6 +4,9 @@
""" """
# remove when we don't support py38 anymore
from __future__ import annotations
from typing import Any
import argparse import argparse
import os import os
import shutil import shutil
@@ -11,7 +14,13 @@ import logging
import configparser import configparser
import sys import sys
from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader from jinja2 import (
Environment,
ChoiceLoader,
FileSystemLoader,
PackageLoader,
Template,
)
import feedgenerator import feedgenerator
from blag.markdown import markdown_factory, convert_markdown from blag.markdown import markdown_factory, convert_markdown
@@ -21,12 +30,12 @@ from blag.quickstart import quickstart
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s %(message)s', format='%(asctime)s %(levelname)s %(name)s %(message)s',
) )
def main(args=None): def main(arguments: list[str] | None = None) -> None:
"""Main entrypoint for the CLI. """Main entrypoint for the CLI.
This method parses the CLI arguments and executes the respective This method parses the CLI arguments and executes the respective
@@ -34,11 +43,11 @@ def main(args=None):
Parameters Parameters
---------- ----------
args : list[str] arguments
optional parameters, used for testing optional parameters, used for testing
""" """
args = parse_args(args) args = parse_args(arguments)
# set loglevel # set loglevel
if args.verbose: if args.verbose:
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@@ -46,12 +55,12 @@ def main(args=None):
args.func(args) args.func(args)
def parse_args(args=None): def parse_args(args: list[str] | None = None) -> argparse.Namespace:
"""Parse command line arguments. """Parse command line arguments.
Parameters Parameters
---------- ----------
args : List[str] args
optional parameters, used for testing optional parameters, used for testing
Returns Returns
@@ -63,10 +72,11 @@ def parse_args(args=None):
parser.add_argument( parser.add_argument(
'--version', '--version',
action='version', action='version',
version='%(prog)s '+__VERSION__, version='%(prog)s ' + __VERSION__,
) )
parser.add_argument( parser.add_argument(
'-v', '--verbose', '-v',
'--verbose',
action='store_true', action='store_true',
help='Verbose output.', help='Verbose output.',
) )
@@ -75,78 +85,86 @@ def parse_args(args=None):
commands.required = True commands.required = True
build_parser = commands.add_parser( build_parser = commands.add_parser(
'build', 'build',
help='Build website.', help='Build website.',
) )
build_parser.set_defaults(func=build) build_parser.set_defaults(func=build)
build_parser.add_argument( build_parser.add_argument(
'-i', '--input-dir', '-i',
default='content', '--input-dir',
help='Input directory (default: content)', default='content',
help='Input directory (default: content)',
) )
build_parser.add_argument( build_parser.add_argument(
'-o', '--output-dir', '-o',
default='build', '--output-dir',
help='Ouptut directory (default: build)', default='build',
help='Ouptut directory (default: build)',
) )
build_parser.add_argument( build_parser.add_argument(
'-t', '--template-dir', '-t',
default='templates', '--template-dir',
help='Template directory (default: templates)', default='templates',
help='Template directory (default: templates)',
) )
build_parser.add_argument( build_parser.add_argument(
'-s', '--static-dir', '-s',
default='static', '--static-dir',
help='Static directory (default: static)', default='static',
help='Static directory (default: static)',
) )
quickstart_parser = commands.add_parser( quickstart_parser = commands.add_parser(
'quickstart', 'quickstart',
help="Quickstart blag, creating necessary configuration.", help="Quickstart blag, creating necessary configuration.",
) )
quickstart_parser.set_defaults(func=quickstart) quickstart_parser.set_defaults(func=quickstart)
serve_parser = commands.add_parser( serve_parser = commands.add_parser(
'serve', 'serve',
help="Start development server.", help="Start development server.",
) )
serve_parser.set_defaults(func=serve) serve_parser.set_defaults(func=serve)
serve_parser.add_argument( serve_parser.add_argument(
'-i', '--input-dir', '-i',
default='content', '--input-dir',
help='Input directory (default: content)', default='content',
help='Input directory (default: content)',
) )
serve_parser.add_argument( serve_parser.add_argument(
'-o', '--output-dir', '-o',
default='build', '--output-dir',
help='Ouptut directory (default: build)', default='build',
help='Ouptut directory (default: build)',
) )
serve_parser.add_argument( serve_parser.add_argument(
'-t', '--template-dir', '-t',
default='templates', '--template-dir',
help='Template directory (default: templates)', default='templates',
help='Template directory (default: templates)',
) )
serve_parser.add_argument( serve_parser.add_argument(
'-s', '--static-dir', '-s',
default='static', '--static-dir',
help='Static directory (default: static)', default='static',
help='Static directory (default: static)',
) )
return parser.parse_args(args) return parser.parse_args(args)
def get_config(configfile): def get_config(configfile: str) -> configparser.SectionProxy:
"""Load site configuration from configfile. """Load site configuration from configfile.
Parameters Parameters
---------- ----------
configfile : str configfile
path to configuration file path to configuration file
Returns Returns
------- -------
dict configparser.SectionProxy
""" """
config = configparser.ConfigParser() config = configparser.ConfigParser()
@@ -166,7 +184,10 @@ def get_config(configfile):
return config['main'] return config['main']
def environment_factory(template_dir=None, globals_=None): def environment_factory(
template_dir: str | None = None,
globals_: dict[str, object] | None = None,
) -> Environment:
"""Environment factory. """Environment factory.
Creates a Jinja2 Environment with the default templates and Creates a Jinja2 Environment with the default templates and
@@ -176,8 +197,9 @@ def environment_factory(template_dir=None, globals_=None):
Parameters Parameters
---------- ----------
template_dir : str template_dir
globals_ : dict directory containing the templates
globals_
Returns Returns
------- -------
@@ -186,7 +208,7 @@ def environment_factory(template_dir=None, globals_=None):
""" """
# first we try the custom templates, and fall back the ones provided # first we try the custom templates, and fall back the ones provided
# by blag # by blag
loaders = [] loaders: list[FileSystemLoader | PackageLoader] = []
if template_dir: if template_dir:
loaders.append(FileSystemLoader([template_dir])) loaders.append(FileSystemLoader([template_dir]))
loaders.append(PackageLoader('blag', 'templates')) loaders.append(PackageLoader('blag', 'templates'))
@@ -196,7 +218,7 @@ def environment_factory(template_dir=None, globals_=None):
return env return env
def build(args): def build(args: argparse.Namespace) -> None:
"""Build the site. """Build the site.
This is blag's main method that builds the site, generates the feed This is blag's main method that builds the site, generates the feed
@@ -204,15 +226,16 @@ def build(args):
Parameters Parameters
---------- ----------
args : argparse.Namespace args
""" """
os.makedirs(f'{args.output_dir}', exist_ok=True) os.makedirs(f'{args.output_dir}', exist_ok=True)
convertibles = [] convertibles = []
for root, dirnames, filenames in os.walk(args.input_dir): for root, dirnames, filenames in os.walk(args.input_dir):
for filename in filenames: for filename in filenames:
rel_src = os.path.relpath(f'{root}/{filename}', rel_src = os.path.relpath(
start=args.input_dir) f'{root}/{filename}', start=args.input_dir
)
# all non-markdown files are just copied over, the markdown # all non-markdown files are just copied over, the markdown
# files are converted to html # files are converted to html
if rel_src.endswith('.md'): if rel_src.endswith('.md'):
@@ -220,8 +243,10 @@ def build(args):
rel_dst = rel_dst[:-3] + '.html' rel_dst = rel_dst[:-3] + '.html'
convertibles.append((rel_src, rel_dst)) convertibles.append((rel_src, rel_dst))
else: else:
shutil.copy(f'{args.input_dir}/{rel_src}', shutil.copy(
f'{args.output_dir}/{rel_src}') f'{args.input_dir}/{rel_src}',
f'{args.output_dir}/{rel_src}',
)
for dirname in dirnames: for dirname in dirnames:
# all directories are copied into the output directory # all directories are copied into the output directory
path = os.path.relpath(f'{root}/{dirname}', start=args.input_dir) path = os.path.relpath(f'{root}/{dirname}', start=args.input_dir)
@@ -251,7 +276,8 @@ def build(args):
) )
generate_feed( generate_feed(
articles, args.output_dir, articles,
args.output_dir,
base_url=config['base_url'], base_url=config['base_url'],
blog_title=config['title'], blog_title=config['title'],
blog_description=config['description'], blog_description=config['description'],
@@ -261,8 +287,13 @@ def build(args):
generate_tags(articles, tags_template, tag_template, args.output_dir) generate_tags(articles, tags_template, tag_template, args.output_dir)
def process_markdown(convertibles, input_dir, output_dir, def process_markdown(
page_template, article_template): convertibles: list[tuple[str, str]],
input_dir: str,
output_dir: str,
page_template: Template,
article_template: Template,
) -> tuple[list[tuple[str, dict[str, Any]]], list[tuple[str, dict[str, Any]]]]:
"""Process markdown files. """Process markdown files.
This method processes the convertibles, converts them to html and This method processes the convertibles, converts them to html and
@@ -273,16 +304,17 @@ def process_markdown(convertibles, input_dir, output_dir,
Parameters Parameters
---------- ----------
convertibles : List[Tuple[str, str]] convertibles
relative paths to markdown- (src) html- (dest) files relative paths to markdown- (src) html- (dest) files
input_dir : str input_dir
output_dir : str output_dir
page_template, archive_template : jinja2 template page_template, archive_template
templats for pages and articles templats for pages and articles
Returns Returns
------- -------
articles, pages : List[Tuple[str, Dict]] list[tuple[str, dict[str, Any]]], list[tuple[str, dict[str, Any]]]
articles and pages
""" """
logger.info("Converting Markdown files...") logger.info("Converting Markdown files...")
@@ -318,37 +350,37 @@ def process_markdown(convertibles, input_dir, output_dir,
def generate_feed( def generate_feed(
articles, articles: list[tuple[str, dict[str, Any]]],
output_dir, output_dir: str,
base_url, base_url: str,
blog_title, blog_title: str,
blog_description, blog_description: str,
blog_author, blog_author: str,
): ) -> None:
"""Generate Atom feed. """Generate Atom feed.
Parameters Parameters
---------- ----------
articles : list[list[str, dict]] articles
list of relative output path and article dictionary list of relative output path and article dictionary
output_dir : str output_dir
where the feed is stored where the feed is stored
base_url : str base_url
base url base url
blog_title : str blog_title
blog title blog title
blog_description : str blog_description
blog description blog description
blog_author : str blog_author
blog author blog author
""" """
logger.info('Generating Atom feed.') logger.info('Generating Atom feed.')
feed = feedgenerator.Atom1Feed( feed = feedgenerator.Atom1Feed(
link=base_url, link=base_url,
title=blog_title, title=blog_title,
description=blog_description, description=blog_description,
feed_url=base_url + 'atom.xml', feed_url=base_url + 'atom.xml',
) )
for dst, context in articles: for dst, context in articles:
@@ -369,16 +401,20 @@ def generate_feed(
feed.write(fh, encoding='utf8') feed.write(fh, encoding='utf8')
def generate_archive(articles, template, output_dir): def generate_archive(
articles: list[tuple[str, dict[str, Any]]],
template: Template,
output_dir: str,
) -> None:
"""Generate the archive page. """Generate the archive page.
Parameters Parameters
---------- ----------
articles : list[list[str, dict]] articles
List of articles. Each article has the destination path and a List of articles. Each article has the destination path and a
dictionary with the content. dictionary with the content.
template : jinja2.Template instance template
output_dir : str output_dir
""" """
archive = [] archive = []
@@ -392,46 +428,52 @@ def generate_archive(articles, template, output_dir):
fh.write(result) fh.write(result)
def generate_tags(articles, tags_template, tag_template, output_dir): def generate_tags(
articles: list[tuple[str, dict[str, Any]]],
tags_template: Template,
tag_template: Template,
output_dir: str,
) -> None:
"""Generate the tags page. """Generate the tags page.
Parameters Parameters
---------- ----------
articles : list[list[str, dict]] articles
List of articles. Each article has the destination path and a List of articles. Each article has the destination path and a
dictionary with the content. dictionary with the content.
tags_template, tag_template : jinja2.Template instance tags_template, tag_template
output_dir : str output_dir
""" """
logger.info("Generating Tag-pages.") logger.info("Generating Tag-pages.")
os.makedirs(f'{output_dir}/tags', exist_ok=True) os.makedirs(f'{output_dir}/tags', exist_ok=True)
# get tags number of occurrences # get tags number of occurrences
all_tags = {} all_tags: dict[str, int] = {}
for _, context in articles: for _, context in articles:
tags = context.get('tags', []) tags: list[str] = context.get('tags', [])
for tag in tags: for tag in tags:
all_tags[tag] = all_tags.get(tag, 0) + 1 all_tags[tag] = all_tags.get(tag, 0) + 1
# sort by occurrence # sort by occurrence
all_tags = sorted(all_tags.items(), key=lambda x: x[1], reverse=True) taglist: list[tuple[str, int]] = sorted(
all_tags.items(), key=lambda x: x[1], reverse=True
)
result = tags_template.render(dict(tags=all_tags)) result = tags_template.render(dict(tags=taglist))
with open(f'{output_dir}/tags/index.html', 'w') as fh: with open(f'{output_dir}/tags/index.html', 'w') as fh:
fh.write(result) fh.write(result)
# get tags and archive per tag # get tags and archive per tag
all_tags = {} all_tags2: dict[str, list[dict[str, Any]]] = {}
for dst, context in articles: for dst, context in articles:
tags = context.get('tags', []) tags = context.get('tags', [])
for tag in tags: for tag in tags:
archive = all_tags.get(tag, []) archive: list[dict[str, Any]] = all_tags2.get(tag, [])
entry = context.copy() entry = context.copy()
entry['dst'] = dst entry['dst'] = dst
archive.append(entry) archive.append(entry)
all_tags[tag] = archive all_tags2[tag] = archive
for tag, archive in all_tags.items(): for tag, archive in all_tags2.items():
result = tag_template.render(dict(archive=archive, tag=tag)) result = tag_template.render(dict(archive=archive, tag=tag))
with open(f'{output_dir}/tags/{tag}.html', 'w') as fh: with open(f'{output_dir}/tags/{tag}.html', 'w') as fh:
fh.write(result) fh.write(result)

View File

@@ -6,12 +6,16 @@ site if necessary.
""" """
# remove when we don't support py38 anymore
from __future__ import annotations
from typing import NoReturn
import os import os
import logging import logging
import time import time
import multiprocessing import multiprocessing
from http.server import SimpleHTTPRequestHandler, HTTPServer from http.server import SimpleHTTPRequestHandler, HTTPServer
from functools import partial from functools import partial
import argparse
from blag import blag from blag import blag
@@ -19,7 +23,7 @@ from blag import blag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_last_modified(dirs): def get_last_modified(dirs: list[str]) -> float:
"""Get the last modified time. """Get the last modified time.
This method recursively goes through `dirs` and returns the most This method recursively goes through `dirs` and returns the most
@@ -27,16 +31,16 @@ def get_last_modified(dirs):
Parameters Parameters
---------- ----------
dirs : list[str] dirs
list of directories to search list of directories to search
Returns Returns
------- -------
int float
most recent modification time found in `dirs` most recent modification time found in `dirs`
""" """
last_mtime = 0 last_mtime = 0.0
for dir in dirs: for dir in dirs:
for root, dirs, files in os.walk(dir): for root, dirs, files in os.walk(dir):
@@ -48,7 +52,7 @@ def get_last_modified(dirs):
return last_mtime return last_mtime
def autoreload(args): def autoreload(args: argparse.Namespace) -> NoReturn:
"""Start the autoreloader. """Start the autoreloader.
This method monitors the given directories for changes (i.e. the This method monitors the given directories for changes (i.e. the
@@ -60,14 +64,15 @@ def autoreload(args):
Parameters Parameters
---------- ----------
args : argparse.Namespace args
contains the input-, template- and static dir
""" """
dirs = [args.input_dir, args.template_dir, args.static_dir] dirs = [args.input_dir, args.template_dir, args.static_dir]
logger.info(f'Monitoring {dirs} for changes...') logger.info(f'Monitoring {dirs} for changes...')
# make sure we trigger the rebuild immediately when we enter the # make sure we trigger the rebuild immediately when we enter the
# loop to avoid serving stale contents # loop to avoid serving stale contents
last_mtime = 0 last_mtime = 0.0
while True: while True:
mtime = get_last_modified(dirs) mtime = get_last_modified(dirs)
if mtime > last_mtime: if mtime > last_mtime:
@@ -77,16 +82,19 @@ def autoreload(args):
time.sleep(1) time.sleep(1)
def serve(args): def serve(args: argparse.Namespace) -> None:
"""Start the webserver and the autoreloader. """Start the webserver and the autoreloader.
Parameters Parameters
---------- ----------
args : arparse.Namespace args
contains the input-, template- and static dir
""" """
httpd = HTTPServer(('', 8000), partial(SimpleHTTPRequestHandler, httpd = HTTPServer(
directory=args.output_dir)) ('', 8000),
partial(SimpleHTTPRequestHandler, directory=args.output_dir),
)
proc = multiprocessing.Process(target=autoreload, args=(args,)) proc = multiprocessing.Process(target=autoreload, args=(args,))
proc.start() proc.start()
logger.info("\n\n Devserver Started -- visit http://localhost:8000\n") logger.info("\n\n Devserver Started -- visit http://localhost:8000\n")

View File

@@ -5,9 +5,12 @@ processing.
""" """
# remove when we don't support py38 anymore
from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging import logging
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
from xml.etree.ElementTree import Element
from markdown import Markdown from markdown import Markdown
from markdown.extensions import Extension from markdown.extensions import Extension
@@ -17,7 +20,7 @@ from markdown.treeprocessors import Treeprocessor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def markdown_factory(): def markdown_factory() -> Markdown:
"""Create a Markdown instance. """Create a Markdown instance.
This method exists only to ensure we use the same Markdown instance This method exists only to ensure we use the same Markdown instance
@@ -30,15 +33,21 @@ def markdown_factory():
""" """
md = Markdown( md = Markdown(
extensions=[ extensions=[
'meta', 'fenced_code', 'codehilite', 'smarty', 'meta',
MarkdownLinkExtension() 'fenced_code',
'codehilite',
'smarty',
MarkdownLinkExtension(),
], ],
output_format='html5', output_format='html',
) )
return md return md
def convert_markdown(md, markdown): def convert_markdown(
md: Markdown,
markdown: str,
) -> tuple[str, dict[str, str]]:
"""Convert markdown into html and extract meta data. """Convert markdown into html and extract meta data.
Some meta data is treated special: Some meta data is treated special:
@@ -48,18 +57,20 @@ def convert_markdown(md, markdown):
Parameters Parameters
---------- ----------
md : markdown.Markdown instance md
markdown : str the Markdown instance
markdown
the markdown text that should be converted
Returns Returns
------- -------
str, dict : str, dict[str, str]
html and metadata html and metadata
""" """
md.reset() md.reset()
content = md.convert(markdown) content = md.convert(markdown)
meta = md.Meta meta = md.Meta # type: ignore
# markdowns metadata consists as list of strings -- one item per # markdowns metadata consists as list of strings -- one item per
# line. let's convert into single strings. # line. let's convert into single strings.
@@ -83,24 +94,26 @@ def convert_markdown(md, markdown):
class MarkdownLinkTreeprocessor(Treeprocessor): class MarkdownLinkTreeprocessor(Treeprocessor):
"""Converts relative links to .md files to .html """Converts relative links to .md files to .html"""
""" def run(self, root: Element) -> Element:
def run(self, root):
for element in root.iter(): for element in root.iter():
if element.tag == 'a': if element.tag == 'a':
url = element.get('href') url = element.get('href')
# element.get could also return None, we haven't seen this so
# far, so lets wait if we raise this
assert url is not None
url = str(url)
converted = self.convert(url) converted = self.convert(url)
element.set('href', converted) element.set('href', converted)
return root return root
def convert(self, url): def convert(self, url: str) -> str:
scheme, netloc, path, query, fragment = urlsplit(url) scheme, netloc, path, query, fragment = urlsplit(url)
logger.debug( logger.debug(
f'{url}: {scheme=} {netloc=} {path=} {query=} {fragment=}' f'{url}: {scheme=} {netloc=} {path=} {query=} {fragment=}'
) )
if (scheme or netloc or not path): if scheme or netloc or not path:
return url return url
if path.endswith('.md'): if path.endswith('.md'):
path = path[:-3] + '.html' path = path[:-3] + '.html'
@@ -110,10 +123,11 @@ class MarkdownLinkTreeprocessor(Treeprocessor):
class MarkdownLinkExtension(Extension): class MarkdownLinkExtension(Extension):
"""markdown.extension that converts relative .md- to .html-links. """markdown.extension that converts relative .md- to .html-links."""
""" def extendMarkdown(self, md: Markdown) -> None:
def extendMarkdown(self, md):
md.treeprocessors.register( md.treeprocessors.register(
MarkdownLinkTreeprocessor(md), 'mdlink', 0, MarkdownLinkTreeprocessor(md),
'mdlink',
0,
) )

View File

@@ -2,10 +2,13 @@
""" """
# remove when we don't support py38 anymore
from __future__ import annotations
import configparser import configparser
import argparse
def get_input(question, default): def get_input(question: str, default: str) -> str:
"""Prompt for user input. """Prompt for user input.
This is a wrapper around the input-builtin. It will show the default answer This is a wrapper around the input-builtin. It will show the default answer
@@ -13,14 +16,15 @@ def get_input(question, default):
Parameters Parameters
---------- ----------
question : str question
the question the user is presented the question the user is presented
default : str default
the default value that will be used if no answer was given the default value that will be used if no answer was given
Returns Returns
------- -------
str str
the answer
""" """
reply = input(f"{question} [{default}]: ") reply = input(f"{question} [{default}]: ")
@@ -29,7 +33,7 @@ def get_input(question, default):
return reply return reply
def quickstart(args): def quickstart(args: argparse.Namespace | None) -> None:
"""Quickstart. """Quickstart.
This method asks the user some questions and generates a This method asks the user some questions and generates a
@@ -37,7 +41,8 @@ def quickstart(args):
Parameters Parameters
---------- ----------
args : argparse.Namespace args
not used
""" """
base_url = get_input( base_url = get_input(
@@ -59,10 +64,10 @@ def quickstart(args):
config = configparser.ConfigParser() config = configparser.ConfigParser()
config['main'] = { config['main'] = {
'base_url': base_url, 'base_url': base_url,
'title': title, 'title': title,
'description': description, 'description': description,
'author': author, 'author': author,
} }
with open('config.ini', 'w') as fh: with open('config.ini', 'w') as fh:
config.write(fh) config.write(fh)

View File

@@ -4,3 +4,5 @@ wheel==0.37.1
pytest==7.1.2 pytest==7.1.2
pytest-cov==3.0.0 pytest-cov==3.0.0
flake8==5.0.4 flake8==5.0.4
mypy==0.971
types-markdown==3.4.1

View File

@@ -7,3 +7,10 @@ addopts =
[flake8] [flake8]
exclude = venv,build,docs exclude = venv,build,docs
[mypy]
files = blag,tests
strict = True
[mypy-feedgenerator.*]
ignore_missing_imports = True

View File

@@ -1,13 +1,18 @@
# remove when we don't support py38 anymore
from __future__ import annotations
from argparse import Namespace
from typing import Iterator, Callable
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import os import os
import pytest import pytest
from jinja2 import Environment, Template
from blag import blag from blag import blag
@pytest.fixture @pytest.fixture
def environment(): def environment() -> Iterator[Environment]:
site = { site = {
'base_url': 'site base_url', 'base_url': 'site base_url',
'title': 'site title', 'title': 'site title',
@@ -19,35 +24,33 @@ def environment():
@pytest.fixture @pytest.fixture
def page_template(environment): def page_template(environment: Environment) -> Iterator[Template]:
yield environment.get_template('page.html') yield environment.get_template('page.html')
@pytest.fixture @pytest.fixture
def article_template(environment): def article_template(environment: Environment) -> Iterator[Template]:
yield environment.get_template('article.html') yield environment.get_template('article.html')
@pytest.fixture @pytest.fixture
def archive_template(environment): def archive_template(environment: Environment) -> Iterator[Template]:
yield environment.get_template('archive.html') yield environment.get_template('archive.html')
@pytest.fixture @pytest.fixture
def tags_template(environment): def tags_template(environment: Environment) -> Iterator[Template]:
yield environment.get_template('tags.html') yield environment.get_template('tags.html')
@pytest.fixture @pytest.fixture
def tag_template(environment): def tag_template(environment: Environment) -> Iterator[Template]:
yield environment.get_template('tag.html') yield environment.get_template('tag.html')
@pytest.fixture @pytest.fixture
def cleandir(): def cleandir() -> Iterator[str]:
"""Create a temporary workind directory and cwd. """Create a temporary workind directory and cwd."""
"""
config = """ config = """
[main] [main]
base_url = https://example.com/ base_url = https://example.com/
@@ -70,17 +73,12 @@ author = a. u. thor
@pytest.fixture @pytest.fixture
def args(cleandir): def args(cleandir: Callable[[], Iterator[str]]) -> Iterator[Namespace]:
class NameSpace: args = Namespace(
def __init__(self, **kwargs): input_dir='content',
for name in kwargs: output_dir='build',
setattr(self, name, kwargs[name]) static_dir='static',
template_dir='templates',
args = NameSpace(
input_dir='content',
output_dir='build',
static_dir='static',
template_dir='templates',
) )
yield args yield args

View File

@@ -1,41 +1,53 @@
# remove when we don't support py38 anymore
from __future__ import annotations
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import os import os
from datetime import datetime from datetime import datetime
from typing import Any
from argparse import Namespace
import pytest import pytest
from pytest import CaptureFixture, LogCaptureFixture
from jinja2 import Template
from blag import __VERSION__
from blag import blag from blag import blag
def test_generate_feed(cleandir): def test_generate_feed(cleandir: str) -> None:
articles = [] articles: list[tuple[str, dict[str, Any]]] = []
blag.generate_feed(articles, 'build', ' ', ' ', ' ', ' ') blag.generate_feed(articles, 'build', ' ', ' ', ' ', ' ')
assert os.path.exists('build/atom.xml') assert os.path.exists('build/atom.xml')
def test_feed(cleandir): def test_feed(cleandir: str) -> None:
articles = [ articles: list[tuple[str, dict[str, Any]]] = [
[ (
'dest1.html', 'dest1.html',
{ {
'title': 'title1', 'title': 'title1',
'date': datetime(2019, 6, 6), 'date': datetime(2019, 6, 6),
'content': 'content1', 'content': 'content1',
} },
], ),
[ (
'dest2.html', 'dest2.html',
{ {
'title': 'title2', 'title': 'title2',
'date': datetime(1980, 5, 9), 'date': datetime(1980, 5, 9),
'content': 'content2', 'content': 'content2',
} },
], ),
] ]
blag.generate_feed(articles, 'build', 'https://example.com/', blag.generate_feed(
'blog title', 'blog description', 'blog author') articles,
'build',
'https://example.com/',
'blog title',
'blog description',
'blog author',
)
with open('build/atom.xml') as fh: with open('build/atom.xml') as fh:
feed = fh.read() feed = fh.read()
@@ -60,18 +72,20 @@ def test_feed(cleandir):
assert '<link href="https://example.com/dest2.html"' in feed assert '<link href="https://example.com/dest2.html"' in feed
def test_generate_feed_with_description(cleandir): def test_generate_feed_with_description(cleandir: str) -> None:
# if a description is provided, it will be used as the summary in # if a description is provided, it will be used as the summary in
# the feed, otherwise we simply use the title of the article # the feed, otherwise we simply use the title of the article
articles = [[ articles: list[tuple[str, dict[str, Any]]] = [
'dest.html', (
{ 'dest.html',
'title': 'title', {
'description': 'description', 'title': 'title',
'date': datetime(2019, 6, 6), 'description': 'description',
'content': 'content', 'date': datetime(2019, 6, 6),
} 'content': 'content',
]] },
)
]
blag.generate_feed(articles, 'build', ' ', ' ', ' ', ' ') blag.generate_feed(articles, 'build', ' ', ' ', ' ', ' ')
with open('build/atom.xml') as fh: with open('build/atom.xml') as fh:
@@ -83,7 +97,7 @@ def test_generate_feed_with_description(cleandir):
assert '<content type="html">content' in feed assert '<content type="html">content' in feed
def test_parse_args_build(): def test_parse_args_build() -> None:
# test default args # test default args
args = blag.parse_args(['build']) args = blag.parse_args(['build'])
assert args.input_dir == 'content' assert args.input_dir == 'content'
@@ -116,7 +130,7 @@ def test_parse_args_build():
assert args.static_dir == 'foo' assert args.static_dir == 'foo'
def test_get_config(): def test_get_config() -> None:
config = """ config = """
[main] [main]
base_url = https://example.com/ base_url = https://example.com/
@@ -138,10 +152,9 @@ author = a. u. thor
# a missing required config causes a sys.exit # a missing required config causes a sys.exit
for x in 'base_url', 'title', 'description', 'author': for x in 'base_url', 'title', 'description', 'author':
config2 = '\n'.join([line config2 = '\n'.join(
for line [line for line in config.splitlines() if not line.startswith(x)]
in config.splitlines() )
if not line.startswith(x)])
with TemporaryDirectory() as dir: with TemporaryDirectory() as dir:
configfile = f'{dir}/config.ini' configfile = f'{dir}/config.ini'
with open(configfile, 'w') as fh: with open(configfile, 'w') as fh:
@@ -166,17 +179,18 @@ author = a. u. thor
assert config_parsed['base_url'] == 'https://example.com/' assert config_parsed['base_url'] == 'https://example.com/'
def test_environment_factory(): def test_environment_factory() -> None:
globals_ = { globals_: dict[str, object] = {'foo': 'bar', 'test': 'me'}
'foo': 'bar',
'test': 'me'
}
env = blag.environment_factory(globals_=globals_) env = blag.environment_factory(globals_=globals_)
assert env.globals['foo'] == 'bar' assert env.globals['foo'] == 'bar'
assert env.globals['test'] == 'me' assert env.globals['test'] == 'me'
def test_process_markdown(cleandir, page_template, article_template): def test_process_markdown(
cleandir: str,
page_template: Template,
article_template: Template,
) -> None:
page1 = """\ page1 = """\
title: some page title: some page
@@ -202,17 +216,12 @@ foo bar
convertibles = [] convertibles = []
for i, txt in enumerate((page1, article1, article2)): for i, txt in enumerate((page1, article1, article2)):
i = str(i) with open(f'content/{str(i)}', 'w') as fh:
with open(f'content/{i}', 'w') as fh:
fh.write(txt) fh.write(txt)
convertibles.append([i, i]) convertibles.append((str(i), str(i)))
articles, pages = blag.process_markdown( articles, pages = blag.process_markdown(
convertibles, convertibles, 'content', 'build', page_template, article_template
'content',
'build',
page_template,
article_template
) )
assert isinstance(articles, list) assert isinstance(articles, list)
@@ -230,7 +239,7 @@ foo bar
assert 'content' in context assert 'content' in context
def test_build(args): def test_build(args: Namespace) -> None:
page1 = """\ page1 = """\
title: some page title: some page
@@ -259,10 +268,9 @@ foo bar
# write some convertibles # write some convertibles
convertibles = [] convertibles = []
for i, txt in enumerate((page1, article1, article2)): for i, txt in enumerate((page1, article1, article2)):
i = str(i) with open(f'{args.input_dir}/{str(i)}.md', 'w') as fh:
with open(f'{args.input_dir}/{i}.md', 'w') as fh:
fh.write(txt) fh.write(txt)
convertibles.append([i, i]) convertibles.append((str(i), str(i)))
# some static files # some static files
with open(f'{args.static_dir}/test', 'w') as fh: with open(f'{args.static_dir}/test', 'w') as fh:
@@ -291,21 +299,21 @@ foo bar
assert os.path.exists(f'{args.output_dir}/tags/bar.html') assert os.path.exists(f'{args.output_dir}/tags/bar.html')
def test_main(cleandir): def test_main(cleandir: str) -> None:
blag.main(['build']) blag.main(['build'])
def test_cli_version(capsys): def test_cli_version(capsys: CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as ex: with pytest.raises(SystemExit) as ex:
blag.main(['--version']) blag.main(['--version'])
# normal system exit # normal system exit
assert ex.value.code == 0 assert ex.value.code == 0
# proper version reported # proper version reported
out, _ = capsys.readouterr() out, _ = capsys.readouterr()
assert blag.__VERSION__ in out assert __VERSION__ in out
def test_cli_verbose(cleandir, caplog): def test_cli_verbose(cleandir: str, caplog: LogCaptureFixture) -> None:
blag.main(['build']) blag.main(['build'])
assert 'DEBUG' not in caplog.text assert 'DEBUG' not in caplog.text

View File

@@ -1,12 +1,15 @@
# remove when we don't support py38 anymore
from __future__ import annotations
import time import time
import threading import threading
from argparse import Namespace
import pytest import pytest
from blag import devserver from blag import devserver
def test_get_last_modified(cleandir): def test_get_last_modified(cleandir: str) -> None:
# take initial time # take initial time
t1 = devserver.get_last_modified(['content']) t1 = devserver.get_last_modified(['content'])
@@ -24,14 +27,16 @@ def test_get_last_modified(cleandir):
assert t2 == t3 assert t2 == t3
def test_autoreload_builds_immediately(args): def test_autoreload_builds_immediately(args: Namespace) -> None:
# create a dummy file that can be build # create a dummy file that can be build
with open('content/test.md', 'w') as fh: with open('content/test.md', 'w') as fh:
fh.write('boo') fh.write('boo')
t = threading.Thread(target=devserver.autoreload, t = threading.Thread(
args=(args, ), target=devserver.autoreload,
daemon=True,) args=(args,),
daemon=True,
)
t0 = devserver.get_last_modified(['build']) t0 = devserver.get_last_modified(['build'])
t.start() t.start()
# try for 5 seconds... # try for 5 seconds...
@@ -44,11 +49,15 @@ def test_autoreload_builds_immediately(args):
assert t1 > t0 assert t1 > t0
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") # noqa @pytest.mark.filterwarnings(
def test_autoreload(args): "ignore::pytest.PytestUnhandledThreadExceptionWarning"
t = threading.Thread(target=devserver.autoreload, )
args=(args, ), def test_autoreload(args: Namespace) -> None:
daemon=True,) t = threading.Thread(
target=devserver.autoreload,
args=(args,),
daemon=True,
)
t.start() t.start()
t0 = devserver.get_last_modified(['build']) t0 = devserver.get_last_modified(['build'])

View File

@@ -1,4 +1,7 @@
# remove when we don't support py38 anymore
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytest import pytest
import markdown import markdown
@@ -6,66 +9,77 @@ import markdown
from blag.markdown import convert_markdown, markdown_factory from blag.markdown import convert_markdown, markdown_factory
@pytest.mark.parametrize("input_, expected", [ @pytest.mark.parametrize(
# inline "input_, expected",
('[test](test.md)', 'test.html'), [
('[test](test.md "test")', 'test.html'), # inline
('[test](a/test.md)', 'a/test.html'), ('[test](test.md)', 'test.html'),
('[test](a/test.md "test")', 'a/test.html'), ('[test](test.md "test")', 'test.html'),
('[test](/test.md)', '/test.html'), ('[test](a/test.md)', 'a/test.html'),
('[test](/test.md "test")', '/test.html'), ('[test](a/test.md "test")', 'a/test.html'),
('[test](/a/test.md)', '/a/test.html'), ('[test](/test.md)', '/test.html'),
('[test](/a/test.md "test")', '/a/test.html'), ('[test](/test.md "test")', '/test.html'),
# reference ('[test](/a/test.md)', '/a/test.html'),
('[test][]\n[test]: test.md ''', 'test.html'), ('[test](/a/test.md "test")', '/a/test.html'),
('[test][]\n[test]: test.md "test"', 'test.html'), # reference
('[test][]\n[test]: a/test.md', 'a/test.html'), ('[test][]\n[test]: test.md ' '', 'test.html'),
('[test][]\n[test]: a/test.md "test"', 'a/test.html'), ('[test][]\n[test]: test.md "test"', 'test.html'),
('[test][]\n[test]: /test.md', '/test.html'), ('[test][]\n[test]: a/test.md', 'a/test.html'),
('[test][]\n[test]: /test.md "test"', '/test.html'), ('[test][]\n[test]: a/test.md "test"', 'a/test.html'),
('[test][]\n[test]: /a/test.md', '/a/test.html'), ('[test][]\n[test]: /test.md', '/test.html'),
('[test][]\n[test]: /a/test.md "test"', '/a/test.html'), ('[test][]\n[test]: /test.md "test"', '/test.html'),
]) ('[test][]\n[test]: /a/test.md', '/a/test.html'),
def test_convert_markdown_links(input_, expected): ('[test][]\n[test]: /a/test.md "test"', '/a/test.html'),
],
)
def test_convert_markdown_links(input_: str, expected: str) -> None:
md = markdown_factory() md = markdown_factory()
html, _ = convert_markdown(md, input_) html, _ = convert_markdown(md, input_)
assert expected in html assert expected in html
@pytest.mark.parametrize("input_, expected", [ @pytest.mark.parametrize(
# scheme "input_, expected",
('[test](https://)', 'https://'), [
# netloc # scheme
('[test](//test.md)', '//test.md'), ('[test](https://)', 'https://'),
# no path # netloc
('[test]()', ''), ('[test](//test.md)', '//test.md'),
]) # no path
def test_dont_convert_normal_links(input_, expected): ('[test]()', ''),
],
)
def test_dont_convert_normal_links(input_: str, expected: str) -> None:
md = markdown_factory() md = markdown_factory()
html, _ = convert_markdown(md, input_) html, _ = convert_markdown(md, input_)
assert expected in html assert expected in html
@pytest.mark.parametrize("input_, expected", [ @pytest.mark.parametrize(
('foo: bar', {'foo': 'bar'}), "input_, expected",
('foo: those are several words', {'foo': 'those are several words'}), [
('tags: this, is, a, test\n', {'tags': ['this', 'is', 'a', 'test']}), ('foo: bar', {'foo': 'bar'}),
('tags: this, IS, a, test', {'tags': ['this', 'is', 'a', 'test']}), ('foo: those are several words', {'foo': 'those are several words'}),
('date: 2020-01-01 12:10', {'date': ('tags: this, is, a, test\n', {'tags': ['this', 'is', 'a', 'test']}),
datetime(2020, 1, 1, 12, 10).astimezone()}), ('tags: this, IS, a, test', {'tags': ['this', 'is', 'a', 'test']}),
]) (
def test_convert_metadata(input_, expected): 'date: 2020-01-01 12:10',
{'date': datetime(2020, 1, 1, 12, 10).astimezone()},
),
],
)
def test_convert_metadata(input_: str, expected: dict[str, Any]) -> None:
md = markdown_factory() md = markdown_factory()
_, meta = convert_markdown(md, input_) _, meta = convert_markdown(md, input_)
assert expected == meta assert expected == meta
def test_markdown_factory(): def test_markdown_factory() -> None:
md = markdown_factory() md = markdown_factory()
assert isinstance(md, markdown.Markdown) assert isinstance(md, markdown.Markdown)
def test_smarty(): def test_smarty() -> None:
md = markdown_factory() md = markdown_factory()
md1 = """ md1 = """
@@ -79,7 +93,7 @@ this --- is -- a test ...
assert 'hellip' in html assert 'hellip' in html
def test_smarty_code(): def test_smarty_code() -> None:
md = markdown_factory() md = markdown_factory()
md1 = """ md1 = """

View File

@@ -1,19 +1,24 @@
# remove when we don't support py38 anymore
from __future__ import annotations
from pytest import MonkeyPatch
from blag.quickstart import get_input, quickstart from blag.quickstart import get_input, quickstart
def test_get_input_default_answer(monkeypatch): def test_get_input_default_answer(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr('builtins.input', lambda x: '') monkeypatch.setattr('builtins.input', lambda x: '')
answer = get_input("foo", "bar") answer = get_input("foo", "bar")
assert answer == 'bar' assert answer == 'bar'
def test_get_input(monkeypatch): def test_get_input(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr('builtins.input', lambda x: 'baz') monkeypatch.setattr('builtins.input', lambda x: 'baz')
answer = get_input("foo", "bar") answer = get_input("foo", "bar")
assert answer == 'baz' assert answer == 'baz'
def test_quickstart(cleandir, monkeypatch): def test_quickstart(cleandir: str, monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr('builtins.input', lambda x: 'foo') monkeypatch.setattr('builtins.input', lambda x: 'foo')
quickstart(None) quickstart(None)
with open('config.ini', 'r') as fh: with open('config.ini', 'r') as fh:

View File

@@ -1,7 +1,11 @@
# remove when we don't support py38 anymore
from __future__ import annotations
import datetime import datetime
from jinja2 import Template
def test_page(page_template):
def test_page(page_template: Template) -> None:
ctx = { ctx = {
'content': 'this is the content', 'content': 'this is the content',
'title': 'this is the title', 'title': 'this is the title',
@@ -11,7 +15,7 @@ def test_page(page_template):
assert 'this is the title' in result assert 'this is the title' in result
def test_article(article_template): def test_article(article_template: Template) -> None:
ctx = { ctx = {
'content': 'this is the content', 'content': 'this is the content',
'title': 'this is the title', 'title': 'this is the title',
@@ -23,7 +27,7 @@ def test_article(article_template):
assert '1980-05-09' in result assert '1980-05-09' in result
def test_archive(archive_template): def test_archive(archive_template: Template) -> None:
entry = { entry = {
'title': 'this is a title', 'title': 'this is a title',
'dst': 'https://example.com/link', 'dst': 'https://example.com/link',
@@ -41,7 +45,7 @@ def test_archive(archive_template):
assert 'https://example.com/link' in result assert 'https://example.com/link' in result
def test_tags(tags_template): def test_tags(tags_template: Template) -> None:
tags = [('foo', 42)] tags = [('foo', 42)]
ctx = { ctx = {
'tags': tags, 'tags': tags,
@@ -54,7 +58,7 @@ def test_tags(tags_template):
assert '42' in result assert '42' in result
def test_tag(tag_template): def test_tag(tag_template: Template) -> None:
entry = { entry = {
'title': 'this is a title', 'title': 'this is a title',
'dst': 'https://example.com/link', 'dst': 'https://example.com/link',

View File

@@ -1,5 +1,8 @@
# remove when we don't support py38 anymore
from __future__ import annotations
import blag import blag
def test_version(): def test_version() -> None:
assert isinstance(blag.__VERSION__, str) assert isinstance(blag.__VERSION__, str)