---
title: Upload a Python Chart and Embed It in a Report
description: Create a deterministic Matplotlib chart, upload it with Requests, and print Markdown and HTML embed snippets.
slug: python-chart-public-url
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: Save a fixed Matplotlib chart as PNG, upload it with Requests, then print a public URL with useful report markup.
cluster: Automation workflows
intent: workflow
sources:
  - title: Requests multipart file upload guide
    url: https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file
  - title: Requests timeout and session guide
    url: https://requests.readthedocs.io/en/master/user/advanced/#timeouts
  - title: Matplotlib Figure savefig API
    url: https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.savefig.html
  - title: Matplotlib pyplot close API
    url: https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.close.html
  - title: imgd.dev OpenAPI document
    url: https://imgd.dev/openapi.json
---

# Upload a Python Chart and Embed It in a Report

Create a fixed Matplotlib PNG, close the figure, then upload the file as the multipart `file` field. The Python script verifies the public URL and prints Markdown and HTML with useful alt text.

imgd.dev charges a one-time **$1 per GB** of storage and has **no free tier**. Fund the account before the first upload. Every imgd.dev image is public. Do not put confidential metrics, customer data, or internal financial data in the chart.

## What makes the chart deterministic

The example uses fixed month labels, fixed request values, fixed colors, fixed ticks, and a fixed 1200 by 675 output size. It uses no random input.

Font rendering can differ across operating systems. The chart content and layout inputs remain fixed.

The save operation finishes before the upload code opens the PNG for reading. `plt.close` also releases the Matplotlib figure in every path.

## Install the example

Save these dependencies in `examples/workflows/python-chart/requirements.txt`:

```text
matplotlib==3.11.1
requests==2.34.2
```

Create a local environment and install them:

```bash
cd examples/workflows/python-chart
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
```

## Complete chart and upload script

Save this file as `examples/workflows/python-chart/chart_to_url.py`:

```python
#!/usr/bin/env python3

from __future__ import annotations

import html
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import requests

UPLOAD_URL = "https://imgd.dev/v1/upload"
ALT_TEXT = (
    "Line chart of monthly report requests from January through June, "
    "rising from 120 to 260."
)


class UploadFailure(RuntimeError):
    pass


def required_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise UploadFailure(f"Set the {name} environment variable.")
    return value


def create_chart(path: Path) -> None:
    months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
    report_requests = [120, 148, 171, 205, 232, 260]

    path.parent.mkdir(parents=True, exist_ok=True)
    plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 11})
    figure, axis = plt.subplots(figsize=(8, 4.5), dpi=150)
    try:
        axis.plot(
            months,
            report_requests,
            color="#2563eb",
            marker="o",
            linewidth=2.5,
        )
        axis.set_title("Monthly report requests")
        axis.set_xlabel("Month")
        axis.set_ylabel("Requests")
        axis.set_ylim(0, 300)
        axis.set_yticks([0, 50, 100, 150, 200, 250, 300])
        axis.grid(axis="y", color="#d1d5db", linewidth=0.8)
        figure.tight_layout()
        figure.savefig(
            path,
            format="png",
            dpi=150,
            metadata={"Software": "imgd.dev Python chart example"},
        )
    finally:
        plt.close(figure)

    if not path.is_file() or path.stat().st_size == 0:
        raise UploadFailure("The chart file was not created.")


def parse_payload(response: requests.Response) -> dict[str, Any]:
    try:
        payload = response.json()
    except ValueError as error:
        raise UploadFailure(f"imgd.dev returned invalid JSON: {error}") from error

    if not isinstance(payload, dict):
        raise UploadFailure("The imgd.dev response must be a JSON object.")
    return payload


def upload_chart(path: Path, key: str) -> dict[str, Any]:
    with path.open("rb") as image_file:
        files = {"file": (path.name, image_file, "image/png")}
        with requests.post(
            UPLOAD_URL,
            headers={"Authorization": f"Bearer {key}"},
            files=files,
            timeout=(10, 60),
        ) as response:
            payload = parse_payload(response)
            if response.status_code not in (200, 202):
                code = payload.get("error", "unknown_error")
                fix = payload.get("fix", "Read the response body.")
                raise UploadFailure(
                    f"Upload failed with HTTP {response.status_code} and {code}: {fix}"
                )

    image_hash = payload.get("hash")
    public_url = payload.get("url")
    if not isinstance(image_hash, str) or re.fullmatch(r"[0-9a-f]{64}", image_hash) is None:
        raise UploadFailure("The success response has no valid image hash.")
    if not isinstance(public_url, str):
        raise UploadFailure("The success response has no public URL.")

    parsed = urlparse(public_url)
    if (
        parsed.scheme != "https"
        or parsed.netloc != "i.imgd.dev"
        or parsed.path != f"/i/{image_hash}"
        or parsed.params
        or parsed.query
        or parsed.fragment
    ):
        raise UploadFailure("The success response has an unexpected public URL.")

    return payload


def verify_public_url(url: str) -> None:
    with requests.get(
        url,
        headers={"Accept": "image/*"},
        timeout=(10, 60),
        stream=True,
    ) as response:
        status_code = response.status_code
        content_type = response.headers.get("Content-Type", "")
        first_chunk = next(response.iter_content(chunk_size=16), b"")
        if not response.ok or not content_type.startswith("image/") or not first_chunk:
            raise UploadFailure(
                "Public URL verification failed with "
                f"HTTP {response.status_code} and Content-Type {content_type}."
            )

    print(
        f"Verified {url} with HTTP {status_code} and Content-Type {content_type}.",
        file=sys.stderr,
    )


def embed_snippets(payload: dict[str, Any]) -> tuple[str, str]:
    url = str(payload["url"])
    width = payload.get("width")
    height = payload.get("height")
    if not isinstance(width, int) or not isinstance(height, int):
        raise UploadFailure("The success response has no numeric image dimensions.")

    markdown = f"![{ALT_TEXT}]({url})"
    html_snippet = (
        f'<img src="{html.escape(url, quote=True)}" '
        f'width="{width}" height="{height}" '
        f'alt="{html.escape(ALT_TEXT, quote=True)}">'
    )
    return markdown, html_snippet


def main() -> None:
    key = required_env("IMGD_KEY")
    chart_path = Path(os.environ.get("CHART_PATH", "monthly-report-requests.png")).resolve()
    if chart_path.suffix.lower() != ".png":
        raise UploadFailure("Use a .png CHART_PATH.")

    create_chart(chart_path)
    payload = upload_chart(chart_path, key)
    verify_public_url(str(payload["url"]))
    markdown, html_snippet = embed_snippets(payload)
    upload_json_path = Path(os.environ.get("UPLOAD_JSON_PATH", "upload.json")).resolve()
    serialized_payload = json.dumps(payload, indent=2)
    upload_json_path.write_text(serialized_payload + "\n", encoding="utf-8")

    print(serialized_payload)
    print("\nMarkdown:\n")
    print(markdown)
    print("\nHTML:\n")
    print(html_snippet)


if __name__ == "__main__":
    try:
        main()
    except (UploadFailure, OSError, requests.RequestException) as error:
        print(str(error), file=sys.stderr)
        raise SystemExit(1) from error
```

The file and both HTTP responses use context managers. They close after success, an API error, a network error, or a parse error.

## Run the chart workflow

Keep `IMGD_KEY` outside the repository. Do not put it in the chart, report, source code, or logs.

```bash
cd examples/workflows/python-chart
. .venv/bin/activate
test -n "$IMGD_KEY"
python chart_to_url.py | tee chart-output.txt
```

The script creates these local files:

- `monthly-report-requests.png`
- `upload.json`
- `chart-output.txt`

A new upload returns HTTP `202`. `upload.json` contains this complete shape:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 73421,
  "width": 1200,
  "height": 675,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}
```

An identical chart can return HTTP `200` with `"deduplicated": true`.

The URL works immediately and can show a blurred image during moderation. The same URL shows the chart after a `live` result.

## Expected report snippets

The script prints Markdown with useful alt text:

```markdown
![Line chart of monthly report requests from January through June, rising from 120 to 260.](https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef)
```

It also prints HTML with the returned dimensions:

```html
<img src="https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" width="1200" height="675" alt="Line chart of monthly report requests from January through June, rising from 120 to 260.">
```

Copy one snippet into the report source. Keep essential conclusions in report text, not only inside the chart.

## Verify the metadata

The script checks that the public URL returns a non-empty image response. Use the owner endpoint for the final state:

```bash
HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .
```

A live response can contain this JSON:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "live",
  "mime": "image/png",
  "bytes": 73421,
  "width": 1200,
  "height": 675,
  "alt_text": "A line chart that shows monthly report requests rising from 120 in January to 260 in June.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "monthly-report-requests.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}
```

A `review` state is terminal and stays blurred. A `blocked` state removes the bytes. Remove a blocked chart from the report.

## Recover from upload errors

The script includes the API `fix` value in its error message.

| Status | Meaning | Recovery |
| --- | --- | --- |
| `401` | The key is missing or invalid. | Set the correct `IMGD_KEY`, then retry. |
| `402` | The account has no storage. | Buy storage and verify funding. There is no free tier. |
| `402` | The chart exceeds the quota. | Add the suggested storage or delete unused images. |
| `413` | The PNG exceeds 20 MB. | Lower the DPI, reduce the figure size, or compress the chart. |
| `415` | The body is not an accepted PNG. | Keep the PNG output and `image/png` multipart type together. |
| `429` | The account exceeded the upload rate. | Wait for the next minute, then retry. |

A representative `401` response has this base shape:

```json
{
  "error": "invalid_api_key",
  "fix": "this key is not recognized; create a new account with POST /v1/accounts"
}
```

Do not create a replacement account in an automated report job. Restore the approved key through your secret process.

## Security notes

- Store `IMGD_KEY` in an environment variable or a secret manager.
- Do not put the key in the report source, image URL, or exception log.
- Treat the chart and URL as public.
- Aggregate or remove sensitive data before chart creation.
- Use useful alt text that states the chart's key relation.
- Remove local output files when local retention is not required.
- Delete unused hosted images when you need to free quota.

## When imgd.dev fits

imgd.dev fits public reports that need a stable chart URL and simple embed markup. The same chart bytes also return the same content hash.

It does not fit confidential reports, row-level customer charts, a free upload, video, or files above 20 MB. Use access-controlled report storage for private data.

## Next guides

- [Add hosted images to Markdown and README files](/blog/host-images-markdown-readme/).
- [Host images for HTML email](/blog/host-images-html-email/).
- [Resize or convert the hosted chart](/blog/resize-convert-image-webp-avif/).

For an agent, follow the [integration procedure](/integrate.md). For human setup, open [the start page](/#start). Read the [service evaluation](/evaluate.md) before you select a host.

## Sources

- [Requests multipart file upload guide](https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file)
- [Requests timeout and session guide](https://requests.readthedocs.io/en/master/user/advanced/#timeouts)
- [Matplotlib Figure savefig API](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.savefig.html)
- [Matplotlib pyplot close API](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.close.html)
- [imgd.dev OpenAPI document](https://imgd.dev/openapi.json)
