From 34d9f02e9c43489aa45a0b6271ae2ecec2a240bd Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Mon, 2 Jun 2025 16:32:55 -0400 Subject: [PATCH 1/6] Solve all the type issues --- .github/workflows/publish.yml | 2 +- frontmatter/__init__.py | 46 +++++++++++++++++++++------------ frontmatter/default_handlers.py | 17 ++++++------ frontmatter/util.py | 19 +++++++++++--- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b465820..c839d16 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: ${{ runner.os }}-publish-pip- - name: Install dependencies run: | - pip install setuptools wheel twine + pip install --upgrade setuptools wheel twine - name: Publish env: TWINE_USERNAME: __token__ diff --git a/frontmatter/__init__.py b/frontmatter/__init__.py index 12a44c7..9302cd8 100644 --- a/frontmatter/__init__.py +++ b/frontmatter/__init__.py @@ -4,12 +4,13 @@ """ from __future__ import annotations -import codecs import io -from typing import TYPE_CHECKING, Iterable +import pathlib +from os import PathLike +from typing import TYPE_CHECKING, Iterable, TextIO -from .util import u -from .default_handlers import YAMLHandler, JSONHandler, TOMLHandler +from .default_handlers import JSONHandler, TOMLHandler, YAMLHandler +from .util import can_open, is_readable, is_writable, u if TYPE_CHECKING: @@ -96,7 +97,7 @@ def parse( return metadata, content.strip() -def check(fd: str | io.IOBase, encoding: str = "utf-8") -> bool: +def check(fd: TextIO | PathLike[str] | str, encoding: str = "utf-8") -> bool: """ Check if a file-like object or filename has a frontmatter, return True if exists, False otherwise. @@ -109,13 +110,17 @@ def check(fd: str | io.IOBase, encoding: str = "utf-8") -> bool: True """ - if hasattr(fd, "read"): + if is_readable(fd): text = fd.read() - else: - with codecs.open(fd, "r", encoding) as f: + elif can_open(fd): + with open(fd, "r", encoding=encoding) as f: text = f.read() + else: + # no idea what we're dealing with + return False + return checks(text, encoding) @@ -138,7 +143,7 @@ def checks(text: str, encoding: str = "utf-8") -> bool: def load( - fd: str | io.IOBase, + fd: str | io.IOBase | pathlib.Path, encoding: str = "utf-8", handler: BaseHandler | None = None, **defaults: object, @@ -154,13 +159,16 @@ def load( ... post = frontmatter.load(f) """ - if hasattr(fd, "read"): + if is_readable(fd): text = fd.read() - else: - with codecs.open(fd, "r", encoding) as f: + elif can_open(fd): + with open(fd, "r", encoding=encoding) as f: text = f.read() + else: + raise ValueError(f"Cannot open filename using type {type(fd)}") + handler = handler or detect_format(text, handlers) return loads(text, encoding, handler, **defaults) @@ -188,7 +196,7 @@ def loads( def dump( post: Post, - fd: str | io.IOBase, + fd: str | PathLike[str] | TextIO, encoding: str = "utf-8", handler: BaseHandler | None = None, **kwargs: object, @@ -231,13 +239,16 @@ def dump( """ content = dumps(post, handler, **kwargs) - if hasattr(fd, "write"): - fd.write(content.encode(encoding)) + if is_writable(fd): + fd.write(content) - else: - with codecs.open(fd, "w", encoding) as f: + elif can_open(fd): + with open(fd, "w", encoding=encoding) as f: f.write(content) + else: + raise ValueError(f"Cannot open filename using type {type(fd)}") + def dumps(post: Post, handler: BaseHandler | None = None, **kwargs: object) -> str: """ @@ -278,6 +289,7 @@ def dumps(post: Post, handler: BaseHandler | None = None, **kwargs: object) -> s if handler is None: handler = getattr(post, "handler", None) or YAMLHandler() + assert handler is not None return handler.format(post, **kwargs) diff --git a/frontmatter/default_handlers.py b/frontmatter/default_handlers.py index f16eb15..f8be870 100644 --- a/frontmatter/default_handlers.py +++ b/frontmatter/default_handlers.py @@ -8,8 +8,8 @@ you don't like YAML. Maybe enjoy writing metadata in JSON, or TOML, or some other exotic markup not yet invented. For this, there are handlers. -This module includes handlers for YAML, JSON and TOML, as well as a -:py:class:`BaseHandler ` that +This module includes handlers for YAML, JSON and TOML, as well as a +:py:class:`BaseHandler ` that outlines the basic API and can be subclassed to deal with new formats. **Note**: The TOML handler is only available if the `toml `_ @@ -32,10 +32,10 @@ An example: -Calling :py:func:`frontmatter.load ` (or :py:func:`loads `) -with the ``handler`` argument tells frontmatter which handler to use. -The handler instance gets saved as an attribute on the returned post -object. By default, calling :py:func:`frontmatter.dumps ` +Calling :py:func:`frontmatter.load ` (or :py:func:`loads `) +with the ``handler`` argument tells frontmatter which handler to use. +The handler instance gets saved as an attribute on the returned post +object. By default, calling :py:func:`frontmatter.dumps ` on the post will use the attached handler. @@ -67,7 +67,7 @@ And this shouldn't break. -Passing a new handler to :py:func:`frontmatter.dumps ` +Passing a new handler to :py:func:`frontmatter.dumps ` (or :py:func:`dump `) changes the export format: :: @@ -283,6 +283,7 @@ class JSONHandler(BaseHandler): END_DELIMITER = "" def split(self, text: str) -> tuple[str, str]: + assert self.FM_BOUNDARY is not None _, fm, content = self.FM_BOUNDARY.split(text, 2) return "{" + fm + "}", content @@ -298,7 +299,7 @@ def export(self, metadata: dict[str, object], **kwargs: object) -> str: if toml: - class TOMLHandler(BaseHandler): + class TOMLHandler(BaseHandler): # type: ignore[no-redef] """ Load and export TOML metadata. diff --git a/frontmatter/util.py b/frontmatter/util.py index bf38eac..4bec05a 100644 --- a/frontmatter/util.py +++ b/frontmatter/util.py @@ -2,16 +2,29 @@ """ Utilities for handling unicode and other repetitive bits """ -from typing import AnyStr +from os import PathLike +from typing import TypeGuard, TextIO -def u(text: AnyStr, encoding: str = "utf-8") -> str: +def is_readable(fd: object) -> TypeGuard[TextIO]: + return callable(getattr(fd, "read")) + + +def is_writable(fd: object) -> TypeGuard[TextIO]: + return callable(getattr(fd, "write")) + + +def can_open(fd: object) -> TypeGuard[str | PathLike[str]]: + return isinstance(fd, str) or isinstance(fd, PathLike) + + +def u(text: str | bytes, encoding: str = "utf-8") -> str: "Return unicode text, no matter what" if isinstance(text, bytes): text_str: str = text.decode(encoding) else: - text_str = text + text_str = str(text) # it's already unicode text_str = text_str.replace("\r\n", "\n") From 1411c7c0d89590d96c70a40d799f04584ec49770 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Mon, 2 Jun 2025 16:35:57 -0400 Subject: [PATCH 2/6] Need a fallback --- frontmatter/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontmatter/util.py b/frontmatter/util.py index 4bec05a..090fe77 100644 --- a/frontmatter/util.py +++ b/frontmatter/util.py @@ -7,11 +7,11 @@ def is_readable(fd: object) -> TypeGuard[TextIO]: - return callable(getattr(fd, "read")) + return callable(getattr(fd, "read", None)) def is_writable(fd: object) -> TypeGuard[TextIO]: - return callable(getattr(fd, "write")) + return callable(getattr(fd, "write", None)) def can_open(fd: object) -> TypeGuard[str | PathLike[str]]: From 5ee190767dc93559b2a9d3088d7be9cdcb553b0d Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Mon, 2 Jun 2025 16:39:17 -0400 Subject: [PATCH 3/6] versions --- .github/workflows/publish.yml | 12 ++++++------ .github/workflows/test.yml | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c839d16..52c6d44 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,14 +9,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Configure pip caching with: path: ~/.cache/pip @@ -32,12 +32,12 @@ jobs: runs-on: ubuntu-latest needs: [test] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.11" - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Configure pip caching with: path: ~/.cache/pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9de22fa..835aefa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,14 +11,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Configure pip caching with: path: ~/.cache/pip From 9281e678fb6a8aafdd84692b9b2cff7cf3561459 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Mon, 2 Jun 2025 16:46:35 -0400 Subject: [PATCH 4/6] StringIO not BytesIO --- .gitignore | 1 + README.md | 6 +++--- docs/index.rst | 4 ++-- frontmatter/__init__.py | 12 ++++++------ 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 72b6c02..3d3fd16 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ var/ .installed.cfg *.egg .eggs/ +.venv # PyInstaller # Usually these files are written by a python script from a template diff --git a/README.md b/README.md index ecf5634..506ba5d 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,10 @@ Well, hello there, world. Or write to a file (or file-like object): ```python ->>> from io import BytesIO ->>> f = BytesIO() +>>> from io import StringIO +>>> f = StringIO() >>> frontmatter.dump(post, f) ->>> print(f.getvalue().decode('utf-8')) # doctest: +NORMALIZE_WHITESPACE +>>> print(f.getvalue()) # doctest: +NORMALIZE_WHITESPACE --- excerpt: tl;dr layout: post diff --git a/docs/index.rst b/docs/index.rst index 611cd65..adc8ba8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -105,8 +105,8 @@ Or write to a file (or file-like object): :: - >>> from io import BytesIO - >>> f = BytesIO() + >>> from io import StringIO + >>> f = StringIO() >>> frontmatter.dump(post, f) >>> print(f.getvalue()) --- diff --git a/frontmatter/__init__.py b/frontmatter/__init__.py index 9302cd8..04f4029 100644 --- a/frontmatter/__init__.py +++ b/frontmatter/__init__.py @@ -207,11 +207,11 @@ def dump( :: - >>> from io import BytesIO + >>> from io import StringIO >>> post = frontmatter.load('tests/yaml/hello-world.txt') - >>> f = BytesIO() + >>> f = StringIO() >>> frontmatter.dump(post, f) - >>> print(f.getvalue().decode('utf-8')) + >>> print(f.getvalue()) --- layout: post title: Hello, world! @@ -222,11 +222,11 @@ def dump( .. testcode:: - from io import BytesIO + from io import StringIO post = frontmatter.load('tests/yaml/hello-world.txt') - f = BytesIO() + f = StringIO() frontmatter.dump(post, f) - print(f.getvalue().decode('utf-8')) + print(f.getvalue()) .. testoutput:: From 9c0fd3f8225a26c445c8fec296cf204c5c42111d Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Mon, 2 Jun 2025 17:29:56 -0400 Subject: [PATCH 5/6] mypy --- .gitignore | 2 ++ frontmatter/default_handlers.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3d3fd16..34259d4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ htmlcov/ .cache nosetests.xml coverage.xml +.mypy_cache +.pytest_cache # Translations *.mo diff --git a/frontmatter/default_handlers.py b/frontmatter/default_handlers.py index f8be870..8c5d8ec 100644 --- a/frontmatter/default_handlers.py +++ b/frontmatter/default_handlers.py @@ -299,7 +299,7 @@ def export(self, metadata: dict[str, object], **kwargs: object) -> str: if toml: - class TOMLHandler(BaseHandler): # type: ignore[no-redef] + class TOMLHandler(BaseHandler): # pyright: ignore """ Load and export TOML metadata. From dc907f5f658d037384a272328ddac9d85964a104 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Wed, 26 Nov 2025 09:08:28 -0500 Subject: [PATCH 6/6] Drop 3.9 --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 52c6d44..db5210e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 835aefa..bad53e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/setup.py b/setup.py index 708d6de..11433ba 100644 --- a/setup.py +++ b/setup.py @@ -40,11 +40,11 @@ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], test_suite="test", )