Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ but with a deprecation period.
- `PathSerializer` now requires the `use_short_options`
keyword-only argument, #80

### Misc

- Reworked documentation and added a Django Modern REST integration example, #109

## 0.4.0

### Features
Expand Down
5 changes: 0 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ To run unit tests only:
make unit
```

`make unit` also runs pytest doctests in Python docstrings, `*.rst` files,
and `README.md`. Write executable examples with `>>>` prompts and expected
output; ordinary Python code fences are not executed. Leave a blank line after
the expected output before closing a Markdown code fence.

To run linting:

```bash
Expand Down
Empty file added docs/__init__.py
Empty file.
171 changes: 171 additions & 0 deletions docs/_ext/show_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""
Execute selected Python examples and display their standard output.

Add ``:show-output:`` to ``literalinclude`` to append a collapsed
``Show Output`` block. Source selection options affect only the displayed
code: the complete file runs in a fresh interpreter during the Sphinx build.
Examples must print their results and finish within the execution timeout.
Use ``:output-language: python`` to highlight Python representations;
output is plain text by default.
"""

import os
import subprocess # noqa: S404
import sys
from pathlib import Path
from typing import ClassVar, Final, override

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.directives.code import LiteralInclude
from sphinx.errors import SphinxError
from sphinx_immaterial.code_annotations import annotations_list

_PROJECT_ROOT: Final = Path(__file__).resolve().parents[2]
_EXECUTION_TIMEOUT: Final = 30


def _example_environment(root: Path) -> dict[str, str]:
"""Preserve build settings while preferring local imports and UTF-8."""
environment = os.environ.copy()
python_path = environment.get('PYTHONPATH')
environment['PYTHONPATH'] = (
os.pathsep.join((str(root), python_path)) if python_path else str(root)
)
environment['PYTHONIOENCODING'] = 'utf-8'
return environment


def _execute_example(example: Path) -> str:
"""
Execute a Python file from the project root and return its stdout.

Use the build's interpreter so examples see the same dependencies as
autodoc. A separate process keeps their imports and global state out of
Sphinx. Nothing is fetched from the generated image URLs.

Raises:
SphinxError: If the file is not Python, fails to execute, exceeds
the timeout, or prints no non-whitespace output.
"""
if example.suffix != '.py':
raise SphinxError(f':show-output: requires a Python file: {example}')

stdout = _run_example(example)
if not stdout.strip():
raise SphinxError(f'Example {example} produced no stdout')
return stdout


def _run_example(example: Path) -> str:
"""Capture stdout and report execution failures with the example path."""
try:
process = subprocess.run( # noqa: S603
[sys.executable, str(example)],
cwd=_PROJECT_ROOT,
env=_example_environment(_PROJECT_ROOT),
capture_output=True,
text=True,
encoding='utf-8',
timeout=_EXECUTION_TIMEOUT,
check=True,
)
except subprocess.CalledProcessError as exc:
raise SphinxError(
f'Example {example} failed (exit {exc.returncode}):\n'
f'{exc.stdout}{exc.stderr}',
) from exc
except subprocess.TimeoutExpired as exc:
raise SphinxError(
f'Example {example} exceeded {_EXECUTION_TIMEOUT} seconds',
) from exc
except OSError as exc:
raise SphinxError(
f'Could not execute example {example}: {exc}'
) from exc
return process.stdout


def _create_output_node(stdout: str, language: str) -> nodes.admonition:
"""
Build a themed, collapsed result containing literal text.

Construct Docutils nodes directly, as in DMR's example extension.
Printed reStructuredText or HTML stays literal instead of being parsed
as documentation markup. Sphinx handles escaping and code highlighting.
"""
literal = stdout.rstrip('\r\n')
output = nodes.literal_block(literal, literal)
output['language'] = language
return nodes.admonition(
'',
nodes.title('', 'Show Output'),
output,
collapsible='',
classes=['show_output'],
)


class ExecutedLiteralInclude(LiteralInclude):
"""
Include source and optionally append the complete file's actual output.

Regular includes keep Sphinx's behavior. Execution is opt-in through
``:show-output:`` and only runs when source inclusion succeeds. The
parent directive tracks the example as a dependency for rebuilds.
"""

option_spec: ClassVar = {
**LiteralInclude.option_spec,
'show-output': directives.flag,
'output-language': directives.unchanged_required,
}

@override
def run(self) -> list[nodes.Node]:
"""Render source, execute the selected example, and append stdout."""
rendered = super().run()
if 'show-output' not in self.options or any(
isinstance(node, nodes.system_message) for node in rendered
):
return rendered

example = Path(self.env.relfn2path(self.arguments[0])[1])
output = _create_output_node(
_execute_example(example),
self.options.get('output-language', 'text'),
)
self.set_source_info(output)
return [*rendered, output]


def _arrange_annotations(_app: Sphinx, document: nodes.document) -> None:
"""
Keep code annotations beside their source, before the generated result.

The theme requires the annotation list immediately after the code block.
Move it ahead of our inserted result so both features work together.
"""
for annotation in document.findall(annotations_list):
parent = annotation.parent
if parent is None:
continue

index = parent.index(annotation)
previous = parent[index - 1] if index else None

if (
isinstance(previous, nodes.admonition)
and ('show_output' in previous.get('classes', ())) # pyright: ignore[reportUnnecessaryContains]
):
parent.remove(annotation)
parent.insert(index - 1, annotation)


def setup(app: Sphinx) -> dict[str, bool]:
"""Replace literalinclude while allowing parallel reads and writes."""
app.add_directive('literalinclude', ExecutedLiteralInclude, override=True)
app.add_css_file('show_output.css')
app.connect('doctree-read', _arrange_annotations)
return {'parallel_read_safe': True, 'parallel_write_safe': True}
11 changes: 11 additions & 0 deletions docs/_static/show_output.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* Keep long generated URLs inside the result panel. */
.md-typeset details.show_output {
min-width: 0;
max-width: 100%;
}

.md-typeset details.show_output pre {
max-width: 100%;
overflow-x: auto;
white-space: pre;
}
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
# We need `imgproxykit` to be importable from here:
_ROOT = Path(__file__).resolve(strict=True).parent.parent
sys.path.insert(0, str(_ROOT))
sys.path.insert(0, str(_ROOT / 'docs' / '_ext'))


# -- Project information -----------------------------------------------------
Expand Down Expand Up @@ -68,6 +69,7 @@ def _get_project_meta() -> dict[str, str]:
'sphinx_autodoc_typehints',
'sphinx_immaterial',
'sphinx_iconify',
'show_output',
]

# If true, Sphinx will warn about all references
Expand Down
Empty file added docs/examples/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions docs/examples/client/default_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from imgproxykit import Client

client = Client('http://localhost:8080')
source_url = 'https://picsum.photos/id/237/1600/1200'

image_url = client.url(source=source_url)
print(image_url)
10 changes: 10 additions & 0 deletions docs/examples/client/plain_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from imgproxykit import Client, Transform

client = Client('http://localhost:8080', source_encoding='plain') # (1)!
source_url = 'https://picsum.photos/id/237/1600/1200' # (2)!

image_url = client.url(
source=source_url,
transform=Transform(width=300, format='webp'),
)
print(image_url)
10 changes: 10 additions & 0 deletions docs/examples/client/short_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from imgproxykit import Client, Transform

client = Client('http://localhost:8080', use_short_options=True) # (1)!
source_url = 'https://picsum.photos/id/237/1600/1200'

image_url = client.url(
source=source_url,
transform=Transform(width=300, quality=85),
)
print(image_url)
13 changes: 1 addition & 12 deletions docs/examples/client/signed_url.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
"""
Sign a URL with example credentials.

>>> imgproxy_url.startswith('http://localhost:8081/unsafe/')
False
>>> imgproxy_url == client.url(source=source_url)
True
"""

from imgproxykit import Client

client = Client(
'http://localhost:8081',
# Replace with your imgproxy server's hex-encoded key and salt.
key='736563726574',
key='736563726574', # (1)!
salt='73616c74',
)
source_url = 'https://picsum.photos/id/237/1600/1200'

imgproxy_url = client.url(source=source_url)

print(imgproxy_url)
17 changes: 2 additions & 15 deletions docs/examples/client/url.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
"""
Resize an image and select its output format.

>>> thumbnail_url.split('/unsafe/')[1].split('/')[0:2]
['resize:fill:300:300', 'format:webp']
"""

from imgproxykit import Client, Transform
from imgproxykit.options import Resize

# Public URL of your imgproxy server.
client = Client('http://localhost:8080')
client = Client('http://localhost:8080') # (1)!
source_url = 'https://picsum.photos/id/237/1600/1200'

thumbnail = Transform(
Resize.fill(width=300, height=300),
format='webp',
)

# Build the URL; imgproxy processes the image when the URL is requested.
thumbnail_url = client.url(source=source_url, transform=thumbnail)

thumbnail_url = client.url(source=source_url, transform=thumbnail) # (2)!
print(thumbnail_url)

image_url = client.url(source=source_url)
print(image_url)
15 changes: 3 additions & 12 deletions docs/examples/getting_started/local_thumbnail.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
"""
Resize an image and select its output format.

>>> thumbnail_url.split('/unsafe/')[1].split('/')[0:2]
['resize:fill:300:300', 'format:webp']
"""

from imgproxykit import Client, Transform
from imgproxykit.options import Resize

# Address of the test imgproxy container.
client = Client('http://localhost:8080')
source_url = 'https://picsum.photos/id/237/1600/1200'
client = Client('http://localhost:8080') # (1)!
source_url = 'https://picsum.photos/id/237/1600/1200' # (2)!

thumbnail = Transform(
Resize.fill(width=300, height=300),
format='webp',
)

# Build the URL; imgproxy processes the image when the URL is requested.
thumbnail_url = client.url(source=source_url, transform=thumbnail)
thumbnail_url = client.url(source=source_url, transform=thumbnail) # (3)!
print(thumbnail_url)
16 changes: 4 additions & 12 deletions docs/examples/getting_started/transformations.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
"""
Resize an image and select its output format.

>>> thumbnail_url.split('/unsafe/')[1].split('/')[0:2]
['resize:fill:300:300', 'format:webp']
"""

from imgproxykit import Client, Transform
from imgproxykit.options import Resize

# Public URL of your imgproxy server.
client = Client('https://imgproxy.example.com')
source_url = 'https://originals.example.com/photo.jpg'
client = Client('https://imgproxy.example.com') # (1)!
source_url = 'https://originals.example.com/photo.jpg' # (2)!

thumbnail = Transform(
Resize.fill(width=300, height=300),
format='webp',
)

# Build the URL; imgproxy processes the image when the URL is requested.
thumbnail_url = client.url(source=source_url, transform=thumbnail)
thumbnail_url = client.url(source=source_url, transform=thumbnail) # (3)!
print(thumbnail_url)
16 changes: 2 additions & 14 deletions docs/examples/getting_started/variants.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
"""
Build a reusable set of product variants.

>>> list(product_urls)
['small', 'medium', 'large']
>>> product_urls['small'] == client.url(source_url, transform=products['small'])
True
"""

from imgproxykit import Client, Transform

client = Client('http://localhost:8080')
source_url = 'https://picsum.photos/id/237/1600/1200'

# Share quality and output format across all variants.
base = Transform(quality=85, format='webp')
base = Transform(quality=85, format='webp') # (1)!
products = {
# Add a width to the shared options for each variant.
'small': base.extend(width=320),
'small': base.extend(width=320), # (2)!
'medium': base.extend(width=640),
'large': base.extend(width=1280),
}
product_urls = client.variants(source=source_url, variants=products)

# Access a URL by its variant name.
print('Small:', product_urls['small'])
print('Medium:', product_urls['medium'])
print('Large:', product_urls['large'])
Empty file.
Empty file.
Loading
Loading