diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..823633f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,4 @@
+root = true
+
+[*.fountain]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 0000000..20c0837
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,27 @@
+name: Pre-commit checks
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.12", "3.13"]
+
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ - name: Install setup uv
+ run: uv sync --frozen --dev
+ - name: Run pre-commit
+ uses: j178/prek-action@v1
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index d2db3e4..eb4d718 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -1,10 +1,9 @@
-name: Python package
+name: Python build and tests
on:
push:
branches: [ "master" ]
pull_request:
- branches: [ "master" ]
jobs:
build:
@@ -12,19 +11,20 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11", "3.13"]
+ python-version: ["3.12", "3.13"]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- cache: "pip"
- name: Install uv
- run: python -m pip install uv
+ uses: astral-sh/setup-uv@v7
+ - name: Install setup uv
+ run: uv sync --frozen --dev
- name: Test
- run: bin/test
+ run: uv run pytest --doctest-modules -W error
- name: Command-line smoke test
run: |
uv run screenplain tests/files/simple.fountain /tmp/simple.pdf
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..bd61969
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,29 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: check-added-large-files
+ - id: trailing-whitespace
+ exclude: \.fountain$
+ - id: no-commit-to-branch
+ args: [--branch, main]
+ - repo: local
+ hooks:
+ - id: ruff-format
+ name: ruff format
+ language: system
+ entry: uv run ruff format
+ pass_filenames: false
+ files: "\\.py$"
+ - id: ruff-check
+ name: ruff check
+ language: system
+ entry: uv run ruff check --fix
+ pass_filenames: false
+ files: "\\.py$"
+ - id: ty
+ name: ty
+ language: system
+ entry: uv run --no-sync ty check
+ files: "\\.pyi?$"
+ pass_filenames: false
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 1b961d8..c7e8894 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "screenplain"
version = "0.12.0"
description = "Convert text file to viewable screenplay."
readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.12"
license = "MIT"
authors = [
{name = "Martin Vilcans", email = "screenplain@librador.com"}
@@ -41,7 +41,8 @@ dev = [
]
[tool.ruff]
-src = ["./"]
+src = ["."]
+include = ["*.py"]
[tool.ruff.lint]
select = [
diff --git a/screenplain/export/fdx.py b/screenplain/export/fdx.py
index 6ca5081..189b2fe 100644
--- a/screenplain/export/fdx.py
+++ b/screenplain/export/fdx.py
@@ -2,25 +2,27 @@
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php
+from typing import TextIO
from xml.sax.saxutils import escape
-from screenplain.richstring import Bold, Italic, Underline
+from screenplain.richstring import Bold, Italic, RichString, Style, Underline
from screenplain.types import (
Action,
Dialog,
DualDialog,
+ Screenplay,
Slug,
Transition,
)
-style_names = {
+style_names: dict[type[Style], str] = {
Bold: "Bold",
Italic: "Italic",
Underline: "Underline",
}
-def _write_text_element(out, styles, text):
+def _write_text_element(out: TextIO, styles: list[str], text: str) -> None:
style_value = "+".join(str(s) for s in styles)
if style_value == "":
out.write(f" {escape(text)}\n")
@@ -28,7 +30,7 @@ def _write_text_element(out, styles, text):
out.write(f' {escape(text)}\n')
-def write_text(out, rich, trailing_linebreak) -> None:
+def write_text(out: TextIO, rich: RichString, trailing_linebreak: bool) -> None:
"""Writes elements."""
for seg_no, segment in enumerate(rich.segments):
fdx_styles = [style_names[n] for n in segment.get_ordered_styles()]
@@ -38,7 +40,12 @@ def write_text(out, rich, trailing_linebreak) -> None:
_write_text_element(out, fdx_styles, segment.text)
-def write_paragraph(out, para_type, lines, centered=False) -> None:
+def write_paragraph(
+ out: TextIO,
+ para_type: str,
+ lines: list[RichString],
+ centered: bool = False,
+) -> None:
if centered:
out.write(f' \n')
else:
@@ -50,7 +57,7 @@ def write_paragraph(out, para_type, lines, centered=False) -> None:
out.write(" \n")
-def write_dialog(out, dialog) -> None:
+def write_dialog(out: TextIO, dialog: Dialog) -> None:
write_paragraph(out, "Character", [dialog.character])
for parenthetical, line in dialog.blocks:
if parenthetical:
@@ -59,14 +66,14 @@ def write_dialog(out, dialog) -> None:
write_paragraph(out, "Dialogue", [line])
-def write_dual_dialog(out, dual) -> None:
+def write_dual_dialog(out: TextIO, dual: DualDialog) -> None:
out.write(" \n \n")
write_dialog(out, dual.left)
write_dialog(out, dual.right)
out.write(" \n \n")
-def to_fdx(screenplay, out) -> None:
+def to_fdx(screenplay: Screenplay, out: TextIO) -> None:
out.write(
'\n'
'\n'
diff --git a/screenplain/export/html.py b/screenplain/export/html.py
index 3268b15..aed2c38 100644
--- a/screenplain/export/html.py
+++ b/screenplain/export/html.py
@@ -2,15 +2,20 @@
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php
+from __future__ import annotations
+
import os
import os.path
+from collections.abc import Callable
+from typing import TextIO
-from screenplain.richstring import plain
+from screenplain.richstring import RichString, plain
from screenplain.types import (
Action,
Dialog,
DualDialog,
PageBreak,
+ Screenplay,
Section,
Slug,
Transition,
@@ -43,24 +48,29 @@ class tag:
"""
- def __init__(self, out, tag, classes=None):
+ def __init__(
+ self, out: TextIO, tag: str, classes: list[str] | set[str] | None = None
+ ) -> None:
self.out = out
self.tag = tag
self.classes = classes
- def __enter__(self):
+ def __enter__(self) -> tag:
if self.classes:
self.out.write(f'<{self.tag} class="{" ".join(self.classes)}">')
else:
self.out.write(f"<{self.tag}>")
+ return self
- def __exit__(self, exception_type, value, traceback):
+ def __exit__(
+ self, exception_type: object, value: object, traceback: object
+ ) -> bool:
if not exception_type:
self.out.write(f"{self.tag}>")
return False
-def to_html(text):
+def to_html(text: RichString) -> str:
html = text.to_html()
if html == "":
return " "
@@ -71,7 +81,7 @@ def to_html(text):
class Formatter:
"""Class for converting paragraphs into HTML."""
- def __init__(self, out):
+ def __init__(self, out: TextIO) -> None:
"""Initializes the formatter.
`out` is a file-like object to write to.
@@ -80,7 +90,7 @@ def __init__(self, out):
"""
self.out = out
- self._format_functions = {
+ self._format_functions: dict[type, Callable[..., None]] = {
Slug: self.format_slug,
Action: self.format_action,
Dialog: self.format_dialog,
@@ -90,7 +100,7 @@ def __init__(self, out):
PageBreak: self.format_page_break,
}
- def convert(self, screenplay) -> None:
+ def convert(self, screenplay: Screenplay) -> None:
"""Converts a number of paragraphs into HTML and writes
it to the output stream.
`screenplay` is a sequence of paragraphs.
@@ -103,11 +113,11 @@ def convert(self, screenplay) -> None:
format_function(para)
self.out.write("\n")
- def format_dialog(self, dialog) -> None:
+ def format_dialog(self, dialog: Dialog) -> None:
with self._tag("div", classes=["dialog"]):
self._write_dialog_block(dialog)
- def format_dual(self, dual) -> None:
+ def format_dual(self, dual: DualDialog) -> None:
with self._tag("div", classes=["dual"]):
with self._tag("div", classes=["left"]):
self._write_dialog_block(dual.left)
@@ -115,7 +125,7 @@ def format_dual(self, dual) -> None:
self._write_dialog_block(dual.right)
self.out.write("
")
- def _write_dialog_block(self, dialog):
+ def _write_dialog_block(self, dialog: Dialog) -> None:
with self._tag("p", classes=["character"]):
self.out.write(to_html(dialog.character))
@@ -124,28 +134,28 @@ def _write_dialog_block(self, dialog):
with self._tag("p", classes=classes):
self.out.write(to_html(text))
- def format_slug(self, slug) -> None:
+ def format_slug(self, slug: Slug) -> None:
num = slug.scene_number
with self._tag("h6"):
if num:
with self._tag("span", classes=["scnuml"]):
- self.out.write(to_html(slug.scene_number))
+ self.out.write(to_html(num))
self.out.write(to_html(slug.line))
if num:
with self._tag("span", classes=["scnumr"]):
- self.out.write(to_html(slug.scene_number))
+ self.out.write(to_html(num))
if slug.synopsis:
with self._tag("span", classes=["h6-synopsis"]):
self.out.write(to_html(plain(slug.synopsis)))
- def format_section(self, section) -> None:
+ def format_section(self, section: Section) -> None:
with self._tag(f"h{section.level}"):
self.out.write(to_html(section.text))
if section.synopsis:
with self._tag("span", classes=[f"h{section.level}-synopsis"]):
self.out.write(to_html(plain(section.synopsis)))
- def format_action(self, para) -> None:
+ def format_action(self, para: Action) -> None:
classes = ["action"]
if para.centered:
classes.append("centered")
@@ -156,23 +166,27 @@ def format_action(self, para) -> None:
self.out.write("
")
self.out.write(to_html(line))
- def format_transition(self, para) -> None:
+ def format_transition(self, para: Transition) -> None:
with self._tag("div", classes=["transition"]):
self.out.write(to_html(para.line))
- def format_page_break(self, para) -> None:
+ def format_page_break(self, para: PageBreak) -> None:
self.page_break_before_next = True
- def _tag(self, tag_name, classes=None):
- if classes is None:
- classes = []
+ def _tag(self, tag_name: str, classes: list[str] | None = None) -> tag:
+ tag_classes: list[str] | set[str] = classes if classes is not None else []
if self.page_break_before_next:
self.page_break_before_next = False
- classes = set(classes).union(("page-break",))
- return tag(self.out, tag_name, classes)
+ tag_classes = set(tag_classes).union(("page-break",))
+ return tag(self.out, tag_name, tag_classes)
-def convert(screenplay, out, css_file=None, bare=False) -> None:
+def convert(
+ screenplay: Screenplay,
+ out: TextIO,
+ css_file: str | None = None,
+ bare: bool = False,
+) -> None:
"""Convert the screenplay into HTML, written to the file-like object `out`.
The output will be a complete HTML document unless `bare` is true.
@@ -188,7 +202,7 @@ def convert(screenplay, out, css_file=None, bare=False) -> None:
)
-def convert_full(screenplay, out, css_file) -> None:
+def convert_full(screenplay: Screenplay, out: TextIO, css_file: str) -> None:
"""Convert the screenplay into a complete HTML document,
written to the file-like object `out`.
@@ -204,7 +218,7 @@ def convert_full(screenplay, out, css_file) -> None:
out.write("