diff --git a/.python-version b/.python-version index 6324d40..c8cfe39 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.10 diff --git a/Makefile b/Makefile index 93aa8c5..e69b7c0 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,12 @@ doc: test: uv run pycodestyle posty tests uv run flake8 posty tests + uv run mypy posty tests uv run pytest +test-watch: + find . -name '*py' -or -name 'uv.lock' | entr -r -c make test + # Release-related actions dist: uv build diff --git a/posty/cli.py b/posty/cli.py index 81a8d2d..ab01139 100644 --- a/posty/cli.py +++ b/posty/cli.py @@ -7,12 +7,12 @@ @click.group() -def cli(): +def cli() -> None: pass @cli.command() -def init(): +def init() -> None: """ Initialize a Posty site """ @@ -45,7 +45,7 @@ def init(): type=click.Path(exists=True), help='Path to your config file', ) -def build(output, config): +def build(output: str, config: str) -> None: """ Build a Posty site as rendered HTML """ @@ -58,7 +58,7 @@ def build(output, config): @cli.group(name='new') -def _new(): +def _new() -> None: """ Create a new post or page """ @@ -71,7 +71,7 @@ def _new(): help='Name of the new page', default='New Page', ) -def page(name): +def page(name: str) -> None: """ Create a new page from the template """ @@ -85,7 +85,7 @@ def page(name): help='Name of the new post', default='New Post', ) -def post(name): +def post(name: str) -> None: """ Create a new page from the template """ @@ -94,7 +94,7 @@ def post(name): @cli.group(name='import') -def _import(): +def _import() -> None: """ Import a site from another static site generator """ @@ -103,7 +103,7 @@ def _import(): @_import.command() @click.argument('path') -def posty1(path): +def posty1(path: str) -> None: """ Import a Posty 1.x site from PATH """ diff --git a/posty/config.py b/posty/config.py index 3120247..3bfc098 100644 --- a/posty/config.py +++ b/posty/config.py @@ -1,17 +1,23 @@ import os.path -import sys import yaml +from dataclasses import dataclass, field from .exceptions import InvalidConfig -if sys.version_info >= (3, 3): - from collections.abc import MutableMapping -else: - from collections import MutableMapping +@dataclass +class FeedConfig: + rss: bool = True + atom: bool = True -class Config(MutableMapping): +@dataclass +class CompatConfig: + redirect_posty1_urls: bool = False + + +@dataclass +class Config: """ Config object that gets passed around to various other objects. Loads config from a given YAML file. @@ -19,63 +25,59 @@ class Config(MutableMapping): :param path: Path to a YAML file to read in as config """ - def __init__(self, path='config.yml'): - self.path = path - self.config = {} - - def load(self): - """ - Load the YAML config from the given path, return the config object - """ - if not os.path.exists(self.path): - raise InvalidConfig( - self, - 'Unable to read config at {}'.format(self.path) - ) - - self.config = yaml.safe_load(open(self.path).read()) - self.clean_config() - return self - - def __len__(self): - return len(self.config) - - def __iter__(self): - return iter(self.config) - def __getitem__(self, key): - return self.config[key] + config_path: str - def __setitem__(self, key, value): - self.config[key] = value + author: str + title: str + description: str = "" + base_url: str = "/" - def __delitem__(self, key): - del self.config[key] + num_top_tags: int = 5 + num_posts_per_page: int = 5 + feeds: FeedConfig = field(default_factory=FeedConfig) + compat: CompatConfig = field(default_factory=CompatConfig) - def clean_config(self): + def __post_init__(self) -> None: """ Validate and clean the already-loaded config """ - c = self.config - - if not c.get('author'): + if self.author == "": raise InvalidConfig(self, 'You must set an author') - if not c.get('title'): + if self.title == "": raise InvalidConfig(self, 'You must set a title') - c.setdefault('description', '') - c.setdefault('base_url', '/') - - if not c['base_url'].endswith('/'): + if not self.base_url.endswith('/'): raise InvalidConfig(self, 'base_url must end with /') - c.setdefault('num_top_tags', 5) - c.setdefault('num_posts_per_page', 5) + @classmethod + def from_yaml(cls, path: str = 'config.yml') -> "Config": + if not os.path.exists(path): + raise ValueError( + 'Unable to read config at {}'.format(path) + ) + + with open(path) as f: + payload = yaml.safe_load(f) + + payload['config_path'] = path + + feed_conf = None + if 'feeds' in payload: + feed_conf = FeedConfig(**payload['feeds']) + del payload['feeds'] + + compat_conf = None + if 'compat' in payload: + compat_conf = CompatConfig(**payload['compat']) + del payload['compat'] + + new_config = cls(**payload) - c.setdefault('feeds', {}) - c['feeds'].setdefault('rss', True) - c['feeds'].setdefault('atom', True) + if feed_conf is not None: + new_config.feeds = feed_conf + if compat_conf is not None: + new_config.compat = compat_conf - c.setdefault('compat', {}) - c['compat'].setdefault('redirect_posty1_urls', False) + return new_config diff --git a/posty/exceptions.py b/posty/exceptions.py index 4522666..3e111ed 100644 --- a/posty/exceptions.py +++ b/posty/exceptions.py @@ -1,11 +1,16 @@ +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from posty.config import Config + + class PostyError(RuntimeError): pass class InvalidConfig(PostyError): - def __init__(self, config_obj, reason): + def __init__(self, config_obj: "Config", reason: str) -> None: msg = 'Invalid config at {}. Reason: {}'.format( - config_obj.path, + config_obj.config_path, reason ) super(self.__class__, self).__init__(msg) diff --git a/posty/importers.py b/posty/importers.py index b4aeae9..2d3fc65 100644 --- a/posty/importers.py +++ b/posty/importers.py @@ -10,6 +10,7 @@ from .exceptions import UnableToImport from .model import ABC +from .site import Site class Importer(ABC): @@ -22,15 +23,15 @@ class Importer(ABC): :param src_path: Path to the thing to import """ - def __init__(self, site, src_path): + def __init__(self, site: Site, src_path: str) -> None: self.site = site self.src_path = src_path @abc.abstractmethod - def run(self): + def run(self) -> None: raise NotImplementedError - def ensure_directories(self): + def ensure_directories(self) -> None: for _dir in ('media', 'templates', 'pages', 'posts'): path = os.path.join(self.site.site_path, _dir) if not os.path.exists(path): @@ -45,7 +46,7 @@ class Posty1Importer(Importer): """ Importer to pull from a Posty 1.x site """ - def run(self): + def run(self) -> None: self.ensure_directories() self.import_media() @@ -53,13 +54,13 @@ def run(self): self.import_pages() self.import_posts() - def import_media(self): + def import_media(self) -> None: self._copy_files('_media', 'media') - def import_templates(self): + def import_templates(self) -> None: self._copy_files('_templates', 'templates') - def import_pages(self): + def import_pages(self) -> None: src_dir = os.path.join(self.src_path, '_pages') dst_dir = os.path.join(self.site.site_path, 'pages') @@ -71,7 +72,7 @@ def import_pages(self): with open(dst_file, 'w') as fh: fh.write(new_page) - def import_posts(self): + def import_posts(self) -> None: src_dir = os.path.join(self.src_path, '_posts') dst_dir = os.path.join(self.site.site_path, 'posts') @@ -83,7 +84,7 @@ def import_posts(self): with open(dst_file, 'w') as fh: fh.write(new_post) - def _copy_files(self, src, dst): + def _copy_files(self, src: str, dst: str) -> None: """ Copy all the files in ``src_dir`` into ``dst_dir``. Each given dir should be relative to the source/destination sites @@ -109,7 +110,7 @@ def _copy_files(self, src, dst): print((" Looks like {} isn't a file nor dir, " "not copying.").format(src_path)) - def _convert_page(self, old_page): + def _convert_page(self, old_page: str) -> str: """ Converts an old Posty 1.x page into a new-style one. Notably just throws away any existing `url` @@ -128,7 +129,7 @@ def _convert_page(self, old_page): return new_page - def _convert_post(self, old_post): + def _convert_post(self, old_post: str) -> str: """ Converts an old Posty post (a string) into a new-style post with a blurb and updated metadata. Returns a string containing the three YAML diff --git a/posty/model.py b/posty/model.py index 7b3ed11..c186f69 100644 --- a/posty/model.py +++ b/posty/model.py @@ -1,20 +1,14 @@ import abc -import sys +from typing import Any from .config import Config -if sys.version_info >= (3, 3): - from collections.abc import MutableMapping -else: - from collections import MutableMapping - - class ABC(metaclass=abc.ABCMeta): pass -class Model(ABC, MutableMapping): +class Model(ABC): """ Base class for objects representing things stored as YAML, such as a Post or a Page @@ -25,48 +19,30 @@ class Model(ABC, MutableMapping): :param config: A Config object """ - def __init__(self, payload, config=None): - self.payload = payload - if config is None: - self.config = Config().load() - else: - self.config = config + _config: Config | None + def __post_init__(self) -> None: self.validate() + @property + def config(self) -> Config: + if self._config is None: + self._config = Config.from_yaml() + return self._config + @classmethod @abc.abstractmethod - def from_yaml(cls, file_contents, config=None): + def from_yaml( + cls, file_contents: str, config: Config | None = None + ) -> "Model": """ Load an object from its YAML file representation """ raise NotImplementedError - def __len__(self): - return len(self.payload) - - def __iter__(self): - return iter(self.payload) - - def __getitem__(self, key): - return self.payload[key] - - def __setitem__(self, key, value): - self.payload[key] = value - - def __delitem__(self, key): - del self.payload[key] - - def as_dict(self): - """ - Return a true dict representation of this object, suitable for - serialization into JSON or YAML - """ - return self.payload - @abc.abstractmethod - def validate(self): + def validate(self) -> None: """ This should be implemented by the child class to verify that all fields that are expected exist on the payload, and set any that aren't @@ -74,14 +50,14 @@ def validate(self): raise NotImplementedError @abc.abstractmethod - def url(self): + def url(self) -> Any: """ Returns the URL path to this resource """ raise NotImplementedError @abc.abstractmethod - def path_on_disk(self): + def path_on_disk(self) -> str: """ Returns the relative path on disk to the object, for rendering purposes """ diff --git a/posty/page.py b/posty/page.py index 1d4178b..9217c08 100644 --- a/posty/page.py +++ b/posty/page.py @@ -1,17 +1,33 @@ +from dataclasses import dataclass from urllib.parse import urljoin +from typing import Any import yaml +from .config import Config from .exceptions import InvalidObject from .model import Model from .util import slugify +@dataclass class Page(Model): """ Representation of a page """ + + title: str + body: str + slug: str = "" + + # Name of the parent page + parent: str | None = None + + _config: Config | None = None + @classmethod - def from_yaml(cls, file_contents, config=None): + def from_yaml( + cls, file_contents: str, config: Config | None = None + ) -> "Page": """ Return a Page from the given file_contents """ @@ -24,35 +40,43 @@ def from_yaml(cls, file_contents, config=None): payload = yaml.safe_load(meta_yaml) payload['body'] = body.strip() - return cls(payload, config=config) + return cls( + title=payload['title'], + body=payload['body'], + _config=config, + ) - def to_yaml(self): + def to_yaml(self) -> str: """ Returns a string of the YAML and text representation of this Post. This is the reverse of from_yaml """ - metadata = {'title': self['title']} - if self['parent']: - metadata['parent'] = self['parent'] + metadata = {'title': self.title} + if self.parent: + metadata['parent'] = self.parent output = yaml.dump(metadata, default_flow_style=False) output += "---\n" - output += self['body'] + output += self.body return output - def validate(self): - required_fields = ('title', 'body') - for field in required_fields: - if field not in self.payload.keys(): - raise InvalidObject('This Page does not have a {} set'.format( - field)) + def validate(self) -> None: + """ + Validate that the page is correct. + + :raises: InvalidObject + """ + if self.title == "": + raise InvalidObject('This Page is missing a title') + if self.body == "": + raise InvalidObject('This Page is missing a body') - self.payload.setdefault('parent') - self.payload.setdefault('slug', slugify(self.payload['title'])) + if self.slug == "": + self.slug = slugify(self.title) - def url(self): - path = '{}/'.format(self.payload['slug']) - return urljoin(self.config['base_url'], path) + def url(self) -> Any: + path = '{}/'.format(self.slug) + return urljoin(self.config.base_url, path) - def path_on_disk(self): - return self.payload['slug'] + def path_on_disk(self) -> str: + return self.slug diff --git a/posty/post.py b/posty/post.py index 5f84f21..544f38a 100644 --- a/posty/post.py +++ b/posty/post.py @@ -1,18 +1,34 @@ +import datetime import os.path -from urllib.parse import urljoin import yaml +from dataclasses import dataclass, field +from urllib.parse import urljoin +from typing import Any +from .config import Config from .exceptions import InvalidObject from .model import Model from .util import slugify +@dataclass class Post(Model): """ Representation of a post """ + + title: str + date: datetime.date + blurb: str + body: str + _config: Config | None + slug: str = '' + tags: list[str] = field(default_factory=list) + @classmethod - def from_yaml(cls, file_contents, config=None): + def from_yaml( + cls, file_contents: str, config: Config | None = None + ) -> "Post": """ Returns a Post from the given file_contents """ @@ -37,56 +53,63 @@ def from_yaml(cls, file_contents, config=None): post['blurb'] = post['blurb'].strip() post['body'] = post['body'].strip() - return cls(post, config=config) + return cls( + title=post.get('title', ''), + slug=post.get('slug', ''), + date=post.get('date'), + tags=post.get('tags', ''), + blurb=post.get('blurb', ''), + body=post.get('body', ''), + _config=config + ) - def to_yaml(self): + def to_yaml(self) -> str: """ Returns the YAML and text representation of this Post. This is the reverse of ``from_yaml()`` """ metadata = { - 'title': self['title'], - 'date': self['date'], - 'tags': self['tags'], + 'title': self.title, + 'date': self.date, + 'tags': self.tags, } - body = self['body'] + body = self.body output = yaml.dump(metadata, default_flow_style=False) - if self['blurb'] != self['body']: + if self.blurb != self.body: output += "---\n" - output += self['blurb'].strip() + output += self.blurb.strip() output += "\n" - body = body.replace(self['blurb'], '') + body = body.replace(self.blurb, '') output += "---\n" output += body.strip() return output - def validate(self): - required_fields = ('title', 'date', 'blurb', 'body') - for field in required_fields: - if field not in self.payload.keys(): - raise InvalidObject( - 'Post is missing a {} field in the metadata'.format(field) - ) + def validate(self) -> None: + if self.title == "": + raise InvalidObject("Must have a title") + + if self.body == "": + raise InvalidObject("Must have a body") - self.payload.setdefault('tags', []) - self.payload.setdefault('slug', slugify(self.payload['title'])) + if self.slug == "": + self.slug = slugify(self.title) - def url(self): + def url(self) -> Any: path = '{}/{:02d}/{}/'.format( - self.payload['date'].year, - self.payload['date'].month, - self.payload['slug'] + self.date.year, + self.date.month, + self.slug ) - return urljoin(self.config['base_url'], path) + return urljoin(self.config.base_url, path) - def path_on_disk(self): + def path_on_disk(self) -> str: return os.path.join( - str(self.payload['date'].year), - '{:02d}'.format(self.payload['date'].month), - self.payload['slug'], + str(self.date.year), + '{:02d}'.format(self.date.month), + self.slug, ) diff --git a/posty/renderer/atom.py b/posty/renderer/atom.py index 1e818f9..f39cee3 100644 --- a/posty/renderer/atom.py +++ b/posty/renderer/atom.py @@ -1,5 +1,6 @@ import os from urllib.parse import urljoin +from typing import Any from .feed import FeedRenderer @@ -10,13 +11,13 @@ class AtomRenderer(FeedRenderer): """ filename = 'atom.xml' - def url(self): + def url(self) -> Any: """ Return the URL to this feed file """ - return urljoin(self.site.config['base_url'], self.filename) + return urljoin(self.site.config.base_url, self.filename) - def output(self): + def output(self) -> None: """ Output the Atom feed file """ diff --git a/posty/renderer/base.py b/posty/renderer/base.py index 9039f1d..cc75079 100644 --- a/posty/renderer/base.py +++ b/posty/renderer/base.py @@ -1,6 +1,10 @@ import abc import copy import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from posty.site import Site class Renderer(metaclass=abc.ABCMeta): @@ -8,16 +12,16 @@ class Renderer(metaclass=abc.ABCMeta): Base class that all renderers inherit off of. Each child class must implement ``render_site()`` with their own rendering logic. """ - def __init__(self, site, output_path='build'): + def __init__(self, site: "Site", output_path: str = 'build') -> None: self.site = copy.deepcopy(site) self.output_path = os.path.join(site.site_path, output_path) @abc.abstractmethod - def render_site(self): + def render_site(self) -> None: raise NotImplementedError # Helper methods - def ensure_output_path(self): + def ensure_output_path(self) -> None: """ Ensure that the output directory ``self.output_path`` exists """ diff --git a/posty/renderer/feed.py b/posty/renderer/feed.py index 728cc50..c8f1957 100644 --- a/posty/renderer/feed.py +++ b/posty/renderer/feed.py @@ -12,26 +12,26 @@ class FeedRenderer(Renderer): """ Base class for all feed Renderers (RSS, Atom) """ - def render_site(self): + def render_site(self) -> None: config = self.site.config self.feed = FeedGenerator() - self.feed.id(config['base_url']) - self.feed.title(config['title']) - self.feed.author({'name': config['author']}) + self.feed.id(config.base_url) + self.feed.title(config.title) + self.feed.author({'name': config.author}) self.feed.copyright(self.site.copyright) - self.feed.link(href=config['base_url'], rel='alternate') + self.feed.link(href=config.base_url, rel='alternate') self.feed.link(href=self.url(), rel='self') - if config['description']: - self.feed.description(config['description']) + if config.description: + self.feed.description(config.description) else: - self.feed.description(config['title']) + self.feed.description(config.title) # Set pubDate to the last post's date pub_date = datetime.combine( - self.site.payload['posts'][0]['date'], + self.site.posts[0].date, time(tzinfo=pytz.utc), ) self.feed.pubDate(pub_date) @@ -39,28 +39,28 @@ def render_site(self): self.render_posts() self.output() - def render_posts(self): + def render_posts(self) -> None: """ Add each post to the feed """ - for post in reversed(self.site.payload['posts']): + for post in reversed(self.site.posts): entry = self.feed.add_entry() entry.id(post.url()) entry.link(href=post.url()) - entry.title(post['title']) + entry.title(post.title) pub_date = datetime.combine( - post['date'], + post.date, time(tzinfo=pytz.utc), ) entry.published(pub_date) markdown = markdown_func(self.site) - entry.summary(markdown(post['blurb'])) - entry.content(markdown(post['body'])) + entry.summary(markdown(post.blurb)) + entry.content(markdown(post.body)) @abc.abstractmethod - def output(self): + def output(self) -> None: """ This method must be implemented by child classes. It gets called during render_site to output the specific file, such as the RSS file or Atom @@ -69,7 +69,7 @@ def output(self): raise NotImplementedError @abc.abstractmethod - def url(self): + def url(self) -> str: """ Return the URL to this feed file """ diff --git a/posty/renderer/html.py b/posty/renderer/html.py index d61683a..d94c1f9 100644 --- a/posty/renderer/html.py +++ b/posty/renderer/html.py @@ -2,10 +2,19 @@ import jinja2 import os from urllib.parse import urljoin +from typing import Any, TYPE_CHECKING + +from posty import util +from posty.page import Page +from posty.post import Post +from posty.renderer.base import Renderer +from posty.renderer.util import ( + markdown_func, media_url_func, absolute_url_func +) + +if TYPE_CHECKING: + from posty.site import Site -from .. import util -from .base import Renderer -from .util import markdown_func, media_url_func, absolute_url_func # Route reference # / Posts @@ -22,7 +31,7 @@ class HtmlRenderer(Renderer): """ Renderer that outputs HTML files """ - def __init__(self, site, output_path='build'): + def __init__(self, site: "Site", output_path: str = 'build') -> None: """ :param site: a Site object to build @@ -42,26 +51,36 @@ def __init__(self, site, output_path='build'): filters['media_url'] = media_url_func(self.site) filters['absolute_url'] = absolute_url_func(self.site) - def _render_file(self, path, template, **kwargs): + def _render_file( + self, + path: str, + template: jinja2.Template, + **kwargs: Any, + ) -> None: with open(path, 'w') as f: f.write(template.render(**kwargs)) - def render_site(self): + def render_site(self) -> None: """ Given a Site object, render all of its components :param site: a loaded Site object """ - for post in self.site.payload['posts']: + for post in self.site.posts: self.render_post(post) - for page in self.site.payload['pages']: + for page in self.site.pages: self.render_page(page) self.render_site_posts() self.render_site_tags() - def render_posts(self, posts, prefix='', template_name='posts.html'): + def render_posts( + self, + posts: list[Post], + prefix: str = '', + template_name: str = 'posts.html', + ) -> None: """ Render a list of posts as sets of pages where each page has ``num_posts_per_page`` posts. Each page of posts will be rendered to @@ -75,9 +94,9 @@ def render_posts(self, posts, prefix='', template_name='posts.html'): prefix += '/' template = self.jinja_env.get_template(template_name) - groups = util.bucket(posts, self.site.config['num_posts_per_page']) + groups = util.bucket(posts, self.site.config.num_posts_per_page) - base_page_url = self.site.config['base_url'] + base_page_url = self.site.config.base_url if prefix: base_page_url = urljoin(base_page_url, prefix) base_page_url = urljoin(base_page_url, 'page/') @@ -92,7 +111,7 @@ def render_posts(self, posts, prefix='', template_name='posts.html'): next_page_url = None if len(groups) > 0: next_page_url = urljoin(base_page_url, str(2) + '/') - self._render_file(dst_file, template, site=self.site.payload, + self._render_file(dst_file, template, site=self.site, posts=posts, next_page_url=next_page_url) # Render the rest @@ -105,7 +124,7 @@ def render_posts(self, posts, prefix='', template_name='posts.html'): dst_file = os.path.join(dst_path, 'index.html') if page == 2: - prev_page_url = urljoin(self.site.config['base_url'], prefix) + prev_page_url = urljoin(self.site.config.base_url, prefix) else: prev_page_url = urljoin(base_page_url, str(page - 1) + '/') next_page_url = None @@ -115,20 +134,20 @@ def render_posts(self, posts, prefix='', template_name='posts.html'): self._render_file( dst_file, template, - site=self.site.payload, + site=self.site, posts=posts, prev_page_url=prev_page_url, next_page_url=next_page_url ) - def render_site_posts(self): + def render_site_posts(self) -> None: """ Renders all of the multi-post pages, N per page """ self.ensure_output_path() - self.render_posts(self.site.payload['posts']) + self.render_posts(self.site.posts) - def render_site_tags(self, template_name='posts.html'): + def render_site_tags(self, template_name: str = 'posts.html') -> None: """ Renders all of the per-tag multi-post pages, N per page """ @@ -136,8 +155,8 @@ def render_site_tags(self, template_name='posts.html'): # Bucket all posts by tag tag_buckets = defaultdict(list) - for post in self.site.payload['posts']: - for tag in post['tags']: + for post in self.site.posts: + for tag in post.tags: tag_buckets[tag].append(post) # For each tag, render pages of posts @@ -145,7 +164,9 @@ def render_site_tags(self, template_name='posts.html'): self.render_posts(posts, prefix='tag/{}/'.format(tag), template_name=template_name) - def render_page(self, page, template_name='page.html'): + def render_page( + self, page: Page, template_name: str = 'page.html' + ) -> None: """ :param page: a Page object """ @@ -158,10 +179,12 @@ def render_page(self, page, template_name='page.html'): os.makedirs(dst_dir) template = self.jinja_env.get_template(template_name) - self._render_file(dst_file, template, site=self.site.payload, + self._render_file(dst_file, template, site=self.site, page=page) - def render_post(self, post, template_name='post.html'): + def render_post( + self, post: Post, template_name: str = 'post.html' + ) -> None: """ :param post: a Post object """ @@ -174,5 +197,5 @@ def render_post(self, post, template_name='post.html'): os.makedirs(dst_dir) template = self.jinja_env.get_template(template_name) - self._render_file(dst_file, template, site=self.site.payload, + self._render_file(dst_file, template, site=self.site, post=post) diff --git a/posty/renderer/json.py b/posty/renderer/json.py index 9711e7e..b4b58f9 100644 --- a/posty/renderer/json.py +++ b/posty/renderer/json.py @@ -2,9 +2,11 @@ import json import os +from dataclasses import asdict +from typing import Any -from .base import Renderer -from .util import markdown_func +from posty.renderer.base import Renderer +from posty.renderer.util import markdown_func class JsonRenderer(Renderer): @@ -12,35 +14,34 @@ class JsonRenderer(Renderer): Renderer that outputs a JSON representation of the Site to ``site.json`` within the output directory """ - def render_site(self): + def render_site(self) -> None: """ Render the Site to ``site.json`` """ self.ensure_output_path() json_path = os.path.join(self.output_path, 'site.json') - payload = { + payload: dict[str, Any] = { 'pages': [], 'posts': [], } markdown = markdown_func(self.site) - for page in self.site.payload['pages']: - p = page.as_dict() + for page in self.site.pages: + p = asdict(page) p['body'] = markdown(p['body']) payload['pages'].append(p) - for post in self.site.payload['posts']: - p = post.as_dict() + for post in self.site.posts: + p = asdict(post) p['blurb'] = markdown(p['blurb']) p['body'] = markdown(p['body']) - p['date'] = post['date'].isoformat() + p['date'] = post.date.isoformat() payload['posts'].append(p) - for k, v in self.site.payload.items(): - if k not in {'posts', 'pages'}: - payload[k] = v + payload['tags'] = self.site.tags + payload['config'] = asdict(self.site.config) with open(json_path, 'w') as f: f.write(json.dumps(payload)) diff --git a/posty/renderer/posty1_redirect.py b/posty/renderer/posty1_redirect.py index 5128743..ff33e18 100644 --- a/posty/renderer/posty1_redirect.py +++ b/posty/renderer/posty1_redirect.py @@ -15,21 +15,21 @@ class Posty1RedirectRenderer(Renderer): Posty2 URLs are in the form of: /:year/:month/:slug/index.html """ - def render_site(self): + def render_site(self) -> None: template_path = os.path.join(self.site.site_path, 'templates/redirect.html') template = jinja2.Template(open(template_path).read()) - for post in self.site.payload['posts']: + for post in self.site.posts: old_dir = os.path.join( self.output_path, - str(post['date'].year), - str(post['date'].month) + str(post.date.year), + str(post.date.month) ) if not os.path.exists(old_dir): os.makedirs(old_dir) - old_slug = slugify_posty1(post['title']) + old_slug = slugify_posty1(post.title) redirect_filename = os.path.join(old_dir, '{}.html'.format(old_slug)) diff --git a/posty/renderer/rss.py b/posty/renderer/rss.py index f337d37..b0c3c94 100644 --- a/posty/renderer/rss.py +++ b/posty/renderer/rss.py @@ -1,5 +1,6 @@ import os from urllib.parse import urljoin +from typing import Any from .feed import FeedRenderer @@ -10,13 +11,13 @@ class RssRenderer(FeedRenderer): """ filename = 'rss.xml' - def url(self): + def url(self) -> Any: """ Return the URL to this feed file """ - return urljoin(self.site.config['base_url'], self.filename) + return urljoin(self.site.config.base_url, self.filename) - def output(self): + def output(self) -> None: """ Output the RSS feed file """ diff --git a/posty/renderer/util.py b/posty/renderer/util.py index c32c732..f00d032 100644 --- a/posty/renderer/util.py +++ b/posty/renderer/util.py @@ -1,6 +1,11 @@ import jinja2 from markdown import markdown as md from urllib.parse import urljoin +from typing import Callable, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from posty.site import Site + # Jinja2 template filters @@ -10,7 +15,7 @@ # dict -def markdown_func(site): +def markdown_func(site: "Site") -> Callable[[str], str]: """ Returns a filter function which will return the rendered version of the given Markdown text. @@ -19,7 +24,8 @@ def markdown_func(site): allows the use of the other filters found here, like ``media_url`` and ``absolute_url``. Then, the result of that is rendered as markdown. """ - def markdown(text): + + def markdown(text: str) -> str: jinja_env = jinja2.Environment() jinja_env.filters['media_url'] = media_url_func(site) jinja_env.filters['absolute_url'] = absolute_url_func(site) @@ -35,7 +41,7 @@ def markdown(text): return markdown -def media_url_func(site): +def media_url_func(site: "Site") -> Callable[[str], str]: """ Returns a filter function that returns a full media URL for the given file, scoped to the given Site object. @@ -43,19 +49,19 @@ def media_url_func(site): For example, if the Site has its base_url set to '/foo/' then: img/my_picture.jpg -> /foo/media/img/my_picture.jpg """ - def media_url(path): - base_path = urljoin(site.config['base_url'], 'media/') + def media_url(path: str) -> Any: + base_path = urljoin(site.config.base_url, 'media/') return urljoin(base_path, path) return media_url -def absolute_url_func(site): +def absolute_url_func(site: "Site") -> Callable[[str], str]: """ Returns a markdown filter function that returns an absolute URL for the given relative URL, simply concatenating config['base_url'] with the URL. """ - def absolute_url(path): - return urljoin(site.config['base_url'], path) + def absolute_url(path: str) -> Any: + return urljoin(site.config.base_url, path) return absolute_url diff --git a/posty/site.py b/posty/site.py index 76a0f96..4d68a9d 100644 --- a/posty/site.py +++ b/posty/site.py @@ -30,35 +30,29 @@ class that conrols everything. :param config_path: Path to the config file, defaults to ``$SITE_PATH/config.yml`` """ - def __init__(self, site_path='.', config_path=None): + def __init__( + self, site_path: str = '.', + config_path: str | None = None, + config: Config | None = None, + ) -> None: self.site_path = site_path - if config_path: - self.config_path = config_path + if config: + self.config = config else: - self.config_path = os.path.join(site_path, 'config.yml') + if config_path: + self.config_path = config_path + else: + self.config_path = os.path.join(site_path, 'config.yml') + self.config = Config.from_yaml(self.config_path) - self._config = None - self.payload = { - 'pages': [], - 'posts': [], - 'tags': [], - } + self.pages: list[Page] = [] + self.posts: list[Post] = [] + self.tags: list[str] = [] self.loaded = False - @property - def config(self): - """ - Returns this site's config as read from the config file - """ - if not self._config: - config_path = os.path.join(self.config_path) - self._config = Config(config_path) - self._config.load() - return self._config - - def init(self): + def init(self) -> None: """ Initialize a new Posty site at the given path """ @@ -74,22 +68,16 @@ def init(self): elif os.path.isfile(src): shutil.copy(src, dst) - def load(self): + def load(self) -> None: """ Load the site from files on disk into our internal representation """ - # Include the whole config - # TODO: deprecate the previous items - self.payload['config'] = dict(self.config) - self._load_pages() self._load_posts() - self.payload['copyright'] = self.copyright - self.loaded = True - def render(self, output_path='build'): + def render(self, output_path: str = 'build') -> None: """ Render the site with the various renderers @@ -101,24 +89,24 @@ def render(self, output_path='build'): HtmlRenderer(self, output_path=output_path).render_site() JsonRenderer(self, output_path=output_path).render_site() - if self.config['feeds']['rss']: + if self.config.feeds.rss: RssRenderer(self, output_path=output_path).render_site() - if self.config['feeds']['atom']: + if self.config.feeds.atom: AtomRenderer(self, output_path=output_path).render_site() - if self.config['compat']['redirect_posty1_urls']: + if self.config.compat.redirect_posty1_urls: Posty1RedirectRenderer(self, output_path=output_path).render_site() - def _load_pages(self): + def _load_pages(self) -> None: pages = [] page_dir = os.path.join(self.site_path, 'pages') for filename in os.listdir(page_dir): contents = open(os.path.join(page_dir, filename)).read() - pages.append(Page.from_yaml(contents, config=self._config)) + pages.append(Page.from_yaml(contents, config=self.config)) - self.payload['pages'] = sorted(pages, key=lambda x: x['title'].lower()) + self.pages = sorted(pages, key=lambda x: x.title.lower()) - def _load_posts(self): + def _load_posts(self) -> None: posts = [] tags = [] @@ -126,18 +114,19 @@ def _load_posts(self): post_dir = os.path.join(self.site_path, 'posts') for filename in os.listdir(post_dir): contents = open(os.path.join(post_dir, filename)).read() - post = Post.from_yaml(contents, config=self._config) + post = Post.from_yaml(contents, config=self.config) posts.append(post) - tags.extend(post['tags']) + tags.extend(post.tags) - self.payload['posts'] = sorted(posts, key=lambda x: x['date'], - reverse=True) + self.posts = sorted( + posts, key=lambda x: x.date, reverse=True + ) # uniquify tags and sort by frequency (descending) - self.payload['tags'] = [t for t, c in Counter(tags).most_common()] + self.tags = [t for t, c in Counter(tags).most_common()] - def post(self, slug): + def post(self, slug: str) -> Post: """ Returns a Post object by its slug @@ -150,19 +139,19 @@ def post(self, slug): :raises PostyError: if no post could be found """ - for post in self.payload['posts']: - post_slug = post['slug'] or slugify(post['title']) + for post in self.posts: + post_slug = post.slug or slugify(post.title) if slug == post_slug: return post else: raise PostyError( 'Unable to find post {}. Available posts: {}'.format( slug, - [slugify(p['title']) for p in self.payload['pages']] + [slugify(p.title) for p in self.pages] ) ) - def page(self, slug): + def page(self, slug: str) -> Page: """ Returns a Page object by its slug @@ -175,37 +164,37 @@ def page(self, slug): :raises PostyError: if no page could be found """ - for page in self.payload['pages']: - page_slug = page.get('slug') or slug == slugify(page['title']) + for page in self.pages: + page_slug = page.slug or slug == slugify(page.title) if slug == page_slug: return page else: raise PostyError( 'Unable to find post {}. Available posts: {}'.format( slug, - [p.get('slug') or slugify(p['title']) - for p in self.payload['pages']] + [p.slug or slugify(p.title) + for p in self.pages] ) ) @property - def copyright(self): + def copyright(self) -> str: """ Returns a string of the copyright info, based on the configured author and the years of the first and last post """ - first_post = self.payload['posts'][-1] - last_post = self.payload['posts'][0] + first_post = self.posts[-1] + last_post = self.posts[0] copyright = 'Copyright {start} - {end}, {author}'.format( - author=self.config['author'], - start=first_post['date'].year, - end=last_post['date'].year + author=self.config.author, + start=first_post.date.year, + end=last_post.date.year ) return copyright - def new_post(self, name="New Post"): + def new_post(self, name: str = "New Post") -> None: """ Create a new post in the site directory from the skeleton post """ @@ -220,13 +209,13 @@ def new_post(self, name="New Post"): skel_path = os.path.join(os.path.dirname(__file__), 'skel/posts/1970-01-01_new-post.yaml') post = Post.from_yaml(open(skel_path).read(), config=self.config) - post['title'] = name - post['date'] = date + post.title = name + post.date = date with open(post_path, 'w') as output_file: output_file.write(post.to_yaml()) - def new_page(self, name="New Page"): + def new_page(self, name: str = "New Page") -> None: """ Create a new page in the site directory from the skeleton page """ @@ -241,7 +230,7 @@ def new_page(self, name="New Page"): 'skel/pages/new-page.yaml') page = Page.from_yaml(open(skel_path).read(), config=self.config) - page['title'] = name + page.title = name with open(page_path, 'w') as output_file: output_file.write(page.to_yaml()) diff --git a/posty/util.py b/posty/util.py index f36e827..3f7bc37 100644 --- a/posty/util.py +++ b/posty/util.py @@ -4,22 +4,26 @@ from slugify import slugify as awesome_slugify +from typing import TypeVar, cast -def slugify(text): +T = TypeVar('T') + + +def slugify(text: str) -> str: """ Returns a slugified version of the given ``text`` """ - return awesome_slugify(text, to_lower=True) + return cast(str, awesome_slugify(text, to_lower=True)) -def slugify_posty1(text): +def slugify_posty1(text: str) -> str: """ Returns a Posty 1.x compatible slugified version of ``text`` """ return str(text).strip().lower().replace(' ', '_').replace('#', '_') -def bucket(_list, size): +def bucket(_list: list[T], size: int) -> list[list[T]]: """ Bucket the list ``_list`` into chunks of up to size ``size`` diff --git a/pyproject.toml b/pyproject.toml index f1169ab..f2379e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,10 @@ dev = [ "sphinx-click", "twine", "wheel", + "mypy>=1.18.2", + "types-markdown>=3.10.0.20251106", + "types-pyyaml>=6.0.12.20250915", + "types-pytz>=2025.2.0.20251108", ] [build-system] @@ -50,3 +54,11 @@ exclude = ['tests'] [tool.hatch.build.targets.sdist] only-packages = true artifacts = ['posty/skel'] + +[tool.mypy] +strict = true + +[[tool.mypy.overrides]] +# Imports which don't have typing +module = ["slugify.*", "feedgen.*"] +ignore_missing_imports = true diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 7bb9c14..258a1c0 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -2,39 +2,43 @@ import pytest import shutil import tempfile +from typing import Generator from posty.config import Config from posty.site import Site @pytest.fixture -def config(): +def config() -> Config: config_path = os.path.join(os.path.dirname(__file__), 'site', 'config.yml') - return Config(path=config_path).load() + return Config.from_yaml(config_path) @pytest.fixture -def posty1_site_path(): +def posty1_site_path() -> str: return os.path.join(os.path.dirname(__file__), 'posty1_site') @pytest.fixture -def empty_posty_site(): +def empty_posty_site() -> Generator[Site, None, None]: path = tempfile.mkdtemp(suffix='posty-test') - site = Site(path) - site._config = Config() + cfg = Config( + config_path=os.path.join(path, "config.yml"), + author="Test Author", + title="Test Blog", + ) + site = Site(path, config=cfg) yield site shutil.rmtree(path) @pytest.fixture -def site(): +def site() -> Generator[Site, None, None]: fixture_path = os.path.join(os.path.dirname(__file__), 'site') path = os.path.join(tempfile.mkdtemp(suffix='posty-test'), 'site') - site = Site(path) - shutil.copytree(fixture_path, path) + site = Site(path) yield site diff --git a/tests/renderer/test_atom.py b/tests/renderer/test_atom.py index 6a867f0..b3fd763 100644 --- a/tests/renderer/test_atom.py +++ b/tests/renderer/test_atom.py @@ -2,17 +2,18 @@ import pytest from posty.renderer import AtomRenderer +from posty.site import Site from ..fixtures import site # noqa @pytest.fixture -def renderer(site): # noqa +def renderer(site: Site) -> AtomRenderer: # noqa site.load() return AtomRenderer(site) -def test_basic_case(renderer): +def test_basic_case(renderer: AtomRenderer) -> None: """ Simple check to see that it spits out a Atom file without bombing out """ diff --git a/tests/renderer/test_html.py b/tests/renderer/test_html.py index 6e4801c..ad23293 100644 --- a/tests/renderer/test_html.py +++ b/tests/renderer/test_html.py @@ -2,17 +2,18 @@ import pytest from posty.renderer import HtmlRenderer +from posty.site import Site from ..fixtures import site # noqa @pytest.fixture -def renderer(site): # noqa +def renderer(site: Site) -> HtmlRenderer: # noqa site.load() return HtmlRenderer(site) -def test_it_at_least_doesnt_crash(renderer): +def test_it_at_least_doesnt_crash(renderer: HtmlRenderer) -> None: # Renders like this are annoying to test. Maybe we can verify what data # is getting passed to the jinja templates, but meh. # @@ -20,7 +21,7 @@ def test_it_at_least_doesnt_crash(renderer): renderer.render_site() -def test_jinja_in_markdown(renderer): +def test_jinja_in_markdown(renderer: HtmlRenderer) -> None: """ If we have jinja inside of our markdown, make sure it gets rendered as expected! This allows folks to use Jinja filters inside markdown! diff --git a/tests/renderer/test_json.py b/tests/renderer/test_json.py index f4901d3..c0f5f68 100644 --- a/tests/renderer/test_json.py +++ b/tests/renderer/test_json.py @@ -3,17 +3,18 @@ import pytest from posty.renderer import JsonRenderer +from posty.site import Site from ..fixtures import site # noqa @pytest.fixture -def renderer(site): # noqa +def renderer(site: Site) -> JsonRenderer: # noqa site.load() return JsonRenderer(site) -def test_render_site(renderer): # noqa +def test_render_site(renderer: JsonRenderer) -> None: # noqa """ Verify that Site.render() spits out a valid JSON file """ diff --git a/tests/renderer/test_posty1.py b/tests/renderer/test_posty1.py index 1325061..63f03ba 100644 --- a/tests/renderer/test_posty1.py +++ b/tests/renderer/test_posty1.py @@ -2,28 +2,29 @@ import pytest from posty.renderer import Posty1RedirectRenderer +from posty.site import Site from posty.util import slugify_posty1 from ..fixtures import site # noqa @pytest.fixture -def renderer(site): # noqa +def renderer(site: Site) -> Posty1RedirectRenderer: # noqa site.load() return Posty1RedirectRenderer(site) -def test_it_at_least_doesnt_crash(renderer): +def test_it_at_least_doesnt_crash(renderer: Posty1RedirectRenderer) -> None: renderer.render_site() -def test_redirects_exist(renderer): +def test_redirects_exist(renderer: Posty1RedirectRenderer) -> None: renderer.render_site() - for post in renderer.site.payload['posts']: + for post in renderer.site.posts: path = os.path.join( renderer.output_path, - str(post['date'].year), - str(post['date'].month), - '{}.html'.format(slugify_posty1(post['title'])), + str(post.date.year), + str(post.date.month), + '{}.html'.format(slugify_posty1(post.title)), ) assert os.path.exists(path) diff --git a/tests/renderer/test_rss.py b/tests/renderer/test_rss.py index 739e78e..8af86a1 100644 --- a/tests/renderer/test_rss.py +++ b/tests/renderer/test_rss.py @@ -2,17 +2,18 @@ import pytest from posty.renderer import RssRenderer +from posty.site import Site from ..fixtures import site # noqa @pytest.fixture -def renderer(site): # noqa +def renderer(site: Site) -> RssRenderer: # noqa site.load() return RssRenderer(site) -def test_basic_case(renderer): +def test_basic_case(renderer: RssRenderer) -> None: """ Simple check to see that it spits out a RSS file without bombing out """ diff --git a/tests/renderer/test_util.py b/tests/renderer/test_util.py index 2a41a0e..adefd0a 100644 --- a/tests/renderer/test_util.py +++ b/tests/renderer/test_util.py @@ -1,9 +1,10 @@ from posty.renderer import util +from posty.site import Site from ..fixtures import site # noqa -def test_markdown(site): # noqa +def test_markdown(site: Site) -> None: # noqa """ Really basic test. No need to test markdown itself, just our use of it """ @@ -12,24 +13,24 @@ def test_markdown(site): # noqa assert result == "
farts.\n
" -def test_media_url_func(site): # noqa +def test_media_url_func(site: Site) -> None: # noqa func = util.media_url_func(site) assert func('jawn') == 'http://example.org/test/media/jawn' -def test_absolute_url_func(site): # noqa +def test_absolute_url_func(site: Site) -> None: # noqa func = util.absolute_url_func(site) assert func('jawn/bot') == 'http://example.org/test/jawn/bot' -def test_jinja_in_markdown(site): # noqa +def test_jinja_in_markdown(site: Site) -> None: # noqa """ If we have jinja inside of our markdown, make sure it gets rendered as expected! This allows folks to use Jinja filters inside markdown! """ site.load() test_page = site.page('jinja-in-markdown') - contents = util.markdown_func(site)(test_page['body']) + contents = util.markdown_func(site)(test_page.body) assert contents == ('

We should be able to put jinja inside of our ' 'templates and have it render totally normally!

') diff --git a/tests/test_config.py b/tests/test_config.py index bb476c6..27fdfab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,45 +6,40 @@ @pytest.fixture -def config(): +def config() -> Config: path = os.path.join(os.path.dirname(__file__), 'fixtures/site/config.yml') - c = Config(path).load() + c = Config.from_yaml(path) return c -def test_config_at_least_loads(config): +def test_config_at_least_loads(config: Config) -> None: """ Make sure the config can load with our skeleton config and it looks somewhat correct when we access it like a Mapping """ - assert config['title'] == 'Test website' - assert config['num_top_tags'] == 5 - assert config['compat']['redirect_posty1_urls'] is True + assert config.title == 'Test website' + assert config.num_top_tags == 5 + assert config.compat.redirect_posty1_urls is True class TestCleanConfig(object): - def test_no_title(self, config): - del config['title'] + def test_no_title(self, config: Config) -> None: + config.title = "" with pytest.raises(InvalidConfig): - config.clean_config() - - def test_no_description(self, config): - del config['description'] - config.clean_config() # shouldn't raise an exception - assert config['description'] == '' - - def test_no_compat(self, config): - del config['compat'] - config.clean_config() - assert config['compat']['redirect_posty1_urls'] is False - - def test_no_base_url(self, config): - del config['base_url'] - config.clean_config() - assert config['base_url'] == '/' - - def test_no_author(self, config): - del config['author'] + config.__post_init__() + def test_no_author(self, config: Config) -> None: + config.author = '' with pytest.raises(InvalidConfig): - config.clean_config() + config.__post_init__() + + def test_defaults(self) -> None: + config = Config( + config_path="/does/not/exist", + title="Test title", + author="Test author", + ) + + assert config.description == '' + assert config.compat.redirect_posty1_urls is False + assert config.base_url == '/' diff --git a/tests/test_import.py b/tests/test_import.py index 50f4ab5..6180607 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -3,19 +3,22 @@ from .fixtures import posty1_site_path, empty_posty_site # noqa from posty.importers import Posty1Importer +from posty.site import Site class TestPosty1Importer(object): @pytest.fixture - def importer(self, posty1_site_path, empty_posty_site): # noqa + def importer(self, posty1_site_path: str, empty_posty_site: Site) -> Posty1Importer: # noqa return Posty1Importer(empty_posty_site, posty1_site_path) @pytest.fixture - def importer_with_directories(self, importer): + def importer_with_directories( + self, importer: Posty1Importer + ) -> Posty1Importer: importer.ensure_directories() return importer - def test_ensure_directories(self, importer): + def test_ensure_directories(self, importer: Posty1Importer) -> None: importer.ensure_directories() dirs = ('posts', 'pages', 'media', 'templates') @@ -23,7 +26,9 @@ def test_ensure_directories(self, importer): path = os.path.join(importer.site.site_path, _dir) assert os.path.isdir(path) - def test_import_media(self, importer_with_directories): + def test_import_media( + self, importer_with_directories: Posty1Importer + ) -> None: """ All media should be copied verbatim """ @@ -37,7 +42,9 @@ def test_import_media(self, importer_with_directories): dst_file = open(os.path.join(dst_path, f)).read() assert src_file == dst_file - def test_import_templates(self, importer_with_directories): + def test_import_templates( + self, importer_with_directories: Posty1Importer + ) -> None: """ all templates should be copied verbatim """ @@ -51,20 +58,23 @@ def test_import_templates(self, importer_with_directories): dst_file = open(os.path.join(dst_path, f)).read() assert src_file == dst_file - def test_import_pages(self, importer_with_directories): + def test_import_pages( + self, importer_with_directories: Posty1Importer + ) -> None: """ all pages should be copies verbatim """ importer = importer_with_directories importer.import_pages() - # Ensure `url` is not set on any pages site = importer.site site._load_pages() - for page in site.payload['pages']: - assert page.get('url') is None + for page in site.pages: + assert page.title != "" - def test_import_posts(self, importer_with_directories): + def test_import_posts( + self, importer_with_directories: Posty1Importer + ) -> None: """ all posts should be copied over with blurbs created from their first paragraphs @@ -76,20 +86,23 @@ def test_import_posts(self, importer_with_directories): site._load_posts() num_posts = len(os.listdir(os.path.join(importer.src_path, '_posts'))) - assert num_posts == len(site.payload['posts']) + assert num_posts == len(site.posts) post = site.post('single-paragraph-post') - assert post['title'] == 'Single paragraph post' - assert post['blurb'] == post['body'] - assert post['body'] == ('This is a post that just has a single ' - 'paragraph') + assert post.title == 'Single paragraph post' + assert post.blurb == post.body + assert post.body == ( + 'This is a post that just has a single paragraph' + ) post = site.post('multi-paragraph-post') - assert post['title'] == 'Multi-paragraph Post' - assert post['blurb'] == ('This is a post that has multiple paragraphs,' - ' where the first paragraph should get ' - 'converted into a blurb.') - assert post['body'] == """ + assert post.title == 'Multi-paragraph Post' + assert post.blurb == ( + 'This is a post that has multiple paragraphs,' + ' where the first paragraph should get ' + 'converted into a blurb.' + ) + assert post.body == """ This is a post that has multiple paragraphs, where the first paragraph should get converted into a blurb. This is the second paragraph, which should be hidden from the blurb. @@ -97,5 +110,5 @@ def test_import_posts(self, importer_with_directories): And a third paragraph, also outside the blurb. """.strip() # noqa - def test_it_at_least_runs(self, importer): + def test_it_at_least_runs(self, importer: Posty1Importer) -> None: importer.run() diff --git a/tests/test_page.py b/tests/test_page.py index 9b73c02..b0997b1 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -1,6 +1,7 @@ import os import pytest +from posty.config import Config from posty.exceptions import InvalidObject from posty.page import Page @@ -8,14 +9,14 @@ @pytest.fixture -def page_contents(): +def page_contents() -> str: path = os.path.join(os.path.dirname(__file__), 'fixtures', 'site', 'pages', 'test.yaml') return open(path).read() @pytest.fixture -def page(config, page_contents): # noqa +def page(config: Config, page_contents: str) -> Page: # noqa """ Basic top-level page (has no parent) """ @@ -23,32 +24,23 @@ def page(config, page_contents): # noqa class TestValidation(object): - def test_basic_case(self, page): + def test_basic_case(self, page: Page) -> None: page.validate() # Should not raise an exception - assert 'parent' in page.keys() - assert page['title'] == 'Test' - assert page['slug'] == 'test' + assert page.title == 'Test' + assert page.slug == 'test' - def test_no_title(self, page): - del page['title'] + def test_no_title(self, page: Page) -> None: + page.title = "" with pytest.raises(InvalidObject): page.validate() - def test_no_parent(self, page): - del page['parent'] - assert 'parent' not in page.payload.keys() - page.validate() - - assert 'parent' in page.payload.keys() - - -def test_url(page): - expected_url = 'http://example.org/test/{}/'.format(page['slug']) +def test_url(page: Page) -> None: + expected_url = 'http://example.org/test/{}/'.format(page.slug) assert page.url() == expected_url -def test_to_yaml(page, page_contents): +def test_to_yaml(page: Page, page_contents: str) -> None: assert page_contents.strip() == page.to_yaml() diff --git a/tests/test_post.py b/tests/test_post.py index 4aa8a47..c52cdf1 100644 --- a/tests/test_post.py +++ b/tests/test_post.py @@ -2,6 +2,7 @@ import os import pytest +from posty.config import Config from posty.exceptions import InvalidObject from posty.post import Post @@ -9,14 +10,14 @@ @pytest.fixture -def post_contents(): +def post_contents() -> str: path = os.path.join(os.path.dirname(__file__), 'fixtures', 'site', 'posts', 'multi-paragraph.yaml') return open(path).read() @pytest.fixture -def post(config, post_contents): # noqa +def post(config: Config, post_contents: str) -> Post: # noqa """ Basic post """ @@ -24,33 +25,29 @@ def post(config, post_contents): # noqa class TestValidation(object): - def test_basic_case(self, post): + def test_basic_case(self, post: Post) -> None: post.validate() # Should not raise an exception - assert post['date'] == datetime.date(2017, 1, 14) - assert post['title'] == 'Multi-paragraph Post' - assert post['slug'] == 'multi-paragraph-post' - assert sorted(post['tags']) == ['blah', 'test'] + assert post.date == datetime.date(2017, 1, 14) + assert post.title == 'Multi-paragraph Post' + assert post.slug == 'multi-paragraph-post' + assert sorted(post.tags) == ['blah', 'test'] - def test_no_title(self, post): - del post['title'] + def test_no_title(self, post: Post) -> None: + post.title = "" with pytest.raises(InvalidObject): post.validate() - def test_no_tags(self, post): - del post['tags'] - post.validate() - assert post['tags'] == [] - -def test_url(post): - year = post['date'].year - month = post['date'].month - expected_url = 'http://example.org/test/{}/{:02d}/{}/'.format(year, month, - post['slug']) +def test_url(post: Post) -> None: + year = post.date.year + month = post.date.month + expected_url = 'http://example.org/test/{}/{:02d}/{}/'.format( + year, month, post.slug + ) assert post.url() == expected_url -def test_to_yaml(post, post_contents): +def test_to_yaml(post: Post, post_contents: str) -> None: assert post_contents.strip() == post.to_yaml() diff --git a/tests/test_site.py b/tests/test_site.py index 1468563..c0afb90 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -2,42 +2,43 @@ import os from .fixtures import site # noqa +from posty.site import Site -def test_site_loads_config(site): # noqa - assert site.config['title'] == 'Test website' +def test_site_loads_config(site: Site) -> None: # noqa + assert site.config.title == 'Test website' -def test_page_sorting(site): # noqa +def test_page_sorting(site: Site) -> None: # noqa """ Ensure pages are sorted alphabetically by their title """ last = '' - for page in site.payload['pages']: - assert last < page['title'] - last = page['title'] + for page in site.pages: + assert last < page.title + last = page.title -def test_post_sorting(site): # noqa +def test_post_sorting(site: Site) -> None: # noqa """ Ensure posts are sorted in reverse chronological order """ last = None - for post in site.payload['posts']: + for post in site.posts: if last is None: - last = post['date'] + last = post.date continue - assert last >= post['date'] - last = post['date'] + assert last >= post.date + last = post.date -def test_copyright(site): # noqa +def test_copyright(site: Site) -> None: # noqa site.load() assert site.copyright == 'Copyright 2010 - 2017, Jimbo Jawn' -def test_new_page(site): # noqa +def test_new_page(site: Site) -> None: # noqa site.new_page() new_page_path = os.path.join(site.site_path, 'pages', 'new-page.yaml') assert os.path.exists(new_page_path) @@ -47,7 +48,7 @@ def test_new_page(site): # noqa assert os.path.exists(new_page_path) -def test_new_post(site): # noqa +def test_new_post(site: Site) -> None: # noqa date = datetime.date.today() site.new_post() diff --git a/tests/test_util.py b/tests/test_util.py index 83a7ef0..254d595 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,7 @@ from posty import util -def test_slugify_posty1(): +def test_slugify_posty1() -> None: cases = ( ( 'North Bay Area Bike Tour Log', @@ -17,7 +17,7 @@ def test_slugify_posty1(): assert util.slugify_posty1(i) == o -def test_bucket(): +def test_bucket() -> None: x = list(range(1, 6)) result = util.bucket(x, 2) diff --git a/uv.lock b/uv.lock index 6da710e..1b4b38a 100644 --- a/uv.lock +++ b/uv.lock @@ -758,6 +758,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, ] +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nh3" version = "0.3.0" @@ -800,6 +854,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -827,6 +890,7 @@ dependencies = [ dev = [ { name = "bpython" }, { name = "flake8" }, + { name = "mypy" }, { name = "pycodestyle" }, { name = "pytest" }, { name = "pytest-watch" }, @@ -835,6 +899,9 @@ dev = [ { name = "sphinx-click" }, { name = "sphinx-rtd-theme" }, { name = "twine" }, + { name = "types-markdown" }, + { name = "types-pytz" }, + { name = "types-pyyaml" }, { name = "wheel" }, ] @@ -853,6 +920,7 @@ requires-dist = [ dev = [ { name = "bpython" }, { name = "flake8" }, + { name = "mypy", specifier = ">=1.18.2" }, { name = "pycodestyle" }, { name = "pytest" }, { name = "pytest-watch" }, @@ -861,6 +929,9 @@ dev = [ { name = "sphinx-click" }, { name = "sphinx-rtd-theme" }, { name = "twine" }, + { name = "types-markdown", specifier = ">=3.10.0.20251106" }, + { name = "types-pytz", specifier = ">=2025.2.0.20251108" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "wheel" }, ] @@ -1386,6 +1457,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] +[[package]] +name = "types-markdown" +version = "3.10.0.20251106" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/e4/060f0dadd9b551cae77d6407f2bc84b168f918d90650454aff219c1b3ed2/types_markdown-3.10.0.20251106.tar.gz", hash = "sha256:12836f7fcbd7221db8baeb0d3a2f820b95050d0824bfa9665c67b4d144a1afa1", size = 19486, upload-time = "2025-11-06T03:06:44.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/58/f666ca9391f2a8bd33bb0b0797cde6ac3e764866708d5f8aec6fab215320/types_markdown-3.10.0.20251106-py3-none-any.whl", hash = "sha256:2c39512a573899b59efae07e247ba088a75b70e3415e81277692718f430afd7e", size = 25862, upload-time = "2025-11-06T03:06:43.082Z" }, +] + +[[package]] +name = "types-pytz" +version = "2025.2.0.20251108" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"