Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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__
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var/
.installed.cfg
*.egg
.eggs/
.venv

# PyInstaller
# Usually these files are written by a python script from a template
Expand All @@ -40,6 +41,8 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
.mypy_cache
.pytest_cache

# Translations
*.mo
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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())
---
Expand Down
58 changes: 35 additions & 23 deletions frontmatter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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)


Expand All @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -199,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!
Expand All @@ -214,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::

Expand All @@ -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:
"""
Expand Down Expand Up @@ -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)


Expand Down
17 changes: 9 additions & 8 deletions frontmatter/default_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <frontmatter.default_handlers.BaseHandler>` that
This module includes handlers for YAML, JSON and TOML, as well as a
:py:class:`BaseHandler <frontmatter.default_handlers.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 <https://pypi.org/project/toml/>`_
Expand All @@ -32,10 +32,10 @@

An example:

Calling :py:func:`frontmatter.load <frontmatter.load>` (or :py:func:`loads <frontmatter.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 <frontmatter.dumps>`
Calling :py:func:`frontmatter.load <frontmatter.load>` (or :py:func:`loads <frontmatter.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 <frontmatter.dumps>`
on the post will use the attached handler.


Expand Down Expand Up @@ -67,7 +67,7 @@
<BLANKLINE>
And this shouldn't break.

Passing a new handler to :py:func:`frontmatter.dumps <frontmatter.dumps>`
Passing a new handler to :py:func:`frontmatter.dumps <frontmatter.dumps>`
(or :py:func:`dump <frontmatter.dump>`) changes the export format:

::
Expand Down Expand Up @@ -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

Expand All @@ -298,7 +299,7 @@ def export(self, metadata: dict[str, object], **kwargs: object) -> str:

if toml:

class TOMLHandler(BaseHandler):
class TOMLHandler(BaseHandler): # pyright: ignore
"""
Load and export TOML metadata.

Expand Down
19 changes: 16 additions & 3 deletions frontmatter/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", None))


def is_writable(fd: object) -> TypeGuard[TextIO]:
return callable(getattr(fd, "write", None))


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")
Expand Down