CtrlK
BlogDocsLog inGet started
Tessl Logo

golikovichev/postman2pytest

Convert a Postman Collection v2.1 JSON file into a runnable pytest test suite using the postman2pytest CLI. Use when the user has a Postman collection (a .postman_collection.json or v2.1 JSON export) and wants to run it as pytest in CI, when migrating from Postman/Newman to a Python-native test stack, when bridging Postman-documented APIs into a pytest-based regression suite, when the user asks to generate pytest tests from Postman, or when the user mentions wanting to keep Postman as the source of truth but run the suite with pytest.

93

1.00x
Quality

100%

Does it follow best practices?

Impact

100%

1.00x

Average score across 2 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

generator.pycore/

"""
Pytest file generator.
Takes a list of ParsedRequest objects and renders them via Jinja2.
"""

from __future__ import annotations

import logging
import re
from datetime import datetime, timezone
from pathlib import Path

from jinja2 import Environment, FileSystemLoader, select_autoescape

from core.parser import ParsedRequest

logger = logging.getLogger(__name__)

_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
_ENV_PREFIX_RE = re.compile(r"^ENV_\w+/?")
_ENV_VAR_RE = re.compile(r"ENV_(\w+)")


def _strip_base_url(url: str) -> str:
    """
    Transform ENV_xxx URLs for use in f-strings inside generated tests.

    ENV_base_url/api/v1/users  ->  api/v1/users
    path/ENV_version/users     ->  path/{os.environ.get('version', '')}/users

    Absolute URLs (http:// or https://) are returned as-is. Note: this filter
    is no longer the only entry point. See _render_url, which routes absolute
    URLs around BASE_URL prepending entirely.
    """
    if url.startswith(("http://", "https://")):
        return url
    url = _ENV_PREFIX_RE.sub("", url)
    url = _ENV_VAR_RE.sub(r"{os.environ.get('\1', '')}", url)
    return url


def _render_url(url: str) -> str:
    """Render a URL as a Python expression for the generated test code.

    Absolute URL (http:// or https://) -> JSON string literal, no BASE_URL.
    Relative URL -> f-string with `{BASE_URL}/` prefix, interpolating any
    ENV_xxx tokens as os.environ.get('xxx', '') lookups.

    Closes the bug where absolute URLs (Postman collections that mix
    internal endpoints with third-party API calls) were emitted as
    f"{BASE_URL}/https://...", producing a broken request URL at runtime.
    """
    import json

    if url.startswith(("http://", "https://")):
        return json.dumps(url)
    stripped = _strip_base_url(url)
    return 'f"{BASE_URL}/' + stripped + '"'


def _escape_fstring_literal(literal: str) -> str:
    """Escape a literal segment for embedding inside a Python f-string.

    f-strings interpret backslash, the outer-quote char, and brace pairs.
    Each must be rewritten so the parser reads the original characters
    back as data, not as syntax.
    """
    return literal.replace("\\", "\\\\").replace('"', '\\"').replace("{", "{{").replace("}", "}}")


def _render_header_value(value: str) -> str:
    """Render a header value as a Python expression.

    Plain values become a JSON string literal: "application/json".
    Values with ENV_xxx become a Python f-string: f"Bearer {os.environ.get('token', '')}"

    Literal portions of the value (everything outside the ENV_xxx tokens)
    must be escaped before they land inside the f-string. Without this
    pass, a captured header containing a double quote or a curly brace
    produces either a broken f-string or, worse, valid Python with extra
    statements injected after the closing quote.
    """
    import json

    if "ENV_" not in value:
        return json.dumps(value)

    parts: list[str] = []
    cursor = 0
    for match in _ENV_VAR_RE.finditer(value):
        parts.append(_escape_fstring_literal(value[cursor : match.start()]))
        parts.append(f"{{os.environ.get('{match.group(1)}', '')}}")
        cursor = match.end()
    parts.append(_escape_fstring_literal(value[cursor:]))
    return 'f"' + "".join(parts) + '"'


def generate(
    requests: list[ParsedRequest],
    collection_name: str,
    output_path: Path,
) -> None:
    """
    Render parsed requests into a pytest file at output_path.
    Creates parent directories if needed.
    """
    if not requests:
        logger.warning("No requests to generate. Output file not written.")
        return

    env = Environment(
        loader=FileSystemLoader(str(_TEMPLATES_DIR)),
        autoescape=select_autoescape([]),
        trim_blocks=True,
        lstrip_blocks=True,
    )
    env.filters["tojson"] = _to_python_repr
    env.filters["strip_base_url"] = _strip_base_url
    env.filters["render_url"] = _render_url
    env.filters["render_header_value"] = _render_header_value

    template = env.get_template("test_collection.jinja2")

    rendered = template.render(
        requests=requests,
        collection_name=collection_name,
        generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
    )

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(rendered, encoding="utf-8")
    logger.info("Written %d tests to %s", len(requests), output_path)


def _to_python_repr(value: object) -> str:
    """Jinja2 filter: render a Python value as a repr-safe string."""
    import json

    return json.dumps(value, ensure_ascii=False)

CHANGELOG.md

CONTRIBUTING.md

main.py

README.md

REFERENCE.md

SECURITY.md

SKILL.md

tessl.json

tile.json