breakscope 0.0.1__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. breakscope-0.1.0/.github/workflows/release.yml +43 -0
  2. {breakscope-0.0.1 → breakscope-0.1.0}/.gitignore +1 -0
  3. {breakscope-0.0.1 → breakscope-0.1.0}/PKG-INFO +20 -2
  4. breakscope-0.1.0/README.md +35 -0
  5. {breakscope-0.0.1 → breakscope-0.1.0}/breakscope/__init__.py +1 -1
  6. breakscope-0.1.0/breakscope/changes.py +93 -0
  7. breakscope-0.1.0/breakscope/cli/main.py +94 -0
  8. breakscope-0.1.0/breakscope/contracts/__init__.py +19 -0
  9. breakscope-0.1.0/breakscope/contracts/diff.py +424 -0
  10. breakscope-0.1.0/breakscope/contracts/loader.py +82 -0
  11. breakscope-0.1.0/breakscope/contracts/model.py +66 -0
  12. breakscope-0.1.0/breakscope/contracts/normalize.py +261 -0
  13. breakscope-0.1.0/breakscope/contracts/resolver.py +101 -0
  14. breakscope-0.1.0/breakscope/contracts/rules.py +54 -0
  15. breakscope-0.1.0/breakscope/errors.py +37 -0
  16. breakscope-0.1.0/breakscope/reports/__init__.py +31 -0
  17. breakscope-0.1.0/breakscope/reports/json.py +33 -0
  18. breakscope-0.1.0/breakscope/reports/terminal.py +78 -0
  19. {breakscope-0.0.1 → breakscope-0.1.0}/docs/ARCHITECTURE-v0.1.md +194 -187
  20. {breakscope-0.0.1 → breakscope-0.1.0}/docs/ROADMAP.md +2 -2
  21. breakscope-0.1.0/examples/demo/api/openapi-v1.yaml +58 -0
  22. breakscope-0.1.0/examples/demo/api/openapi-v2.yaml +52 -0
  23. breakscope-0.1.0/tests/contracts/__init__.py +0 -0
  24. breakscope-0.1.0/tests/contracts/__snapshots__/test_rules.ambr +799 -0
  25. breakscope-0.1.0/tests/contracts/corpus/README.md +7 -0
  26. breakscope-0.1.0/tests/contracts/corpus/v30-link-example.yaml +203 -0
  27. breakscope-0.1.0/tests/contracts/corpus/v30-petstore-expanded.yaml +156 -0
  28. breakscope-0.1.0/tests/contracts/corpus/v30-petstore.yaml +119 -0
  29. breakscope-0.1.0/tests/contracts/corpus/v31-non-oauth-scopes.yaml +18 -0
  30. breakscope-0.1.0/tests/contracts/corpus/v31-webhook-example.yaml +34 -0
  31. breakscope-0.1.0/tests/contracts/make_rule_fixtures.py +215 -0
  32. breakscope-0.1.0/tests/contracts/rules/endpoint.added/new.yaml +86 -0
  33. breakscope-0.1.0/tests/contracts/rules/endpoint.added/old.yaml +82 -0
  34. breakscope-0.1.0/tests/contracts/rules/endpoint.removed/new.yaml +63 -0
  35. breakscope-0.1.0/tests/contracts/rules/endpoint.removed/old.yaml +82 -0
  36. breakscope-0.1.0/tests/contracts/rules/parameter.added.optional/new.yaml +86 -0
  37. breakscope-0.1.0/tests/contracts/rules/parameter.added.optional/old.yaml +82 -0
  38. breakscope-0.1.0/tests/contracts/rules/parameter.added.required/new.yaml +87 -0
  39. breakscope-0.1.0/tests/contracts/rules/parameter.added.required/old.yaml +82 -0
  40. breakscope-0.1.0/tests/contracts/rules/parameter.became_required/new.yaml +83 -0
  41. breakscope-0.1.0/tests/contracts/rules/parameter.became_required/old.yaml +82 -0
  42. breakscope-0.1.0/tests/contracts/rules/parameter.enum.value_removed/new.yaml +81 -0
  43. breakscope-0.1.0/tests/contracts/rules/parameter.enum.value_removed/old.yaml +82 -0
  44. breakscope-0.1.0/tests/contracts/rules/parameter.removed/new.yaml +75 -0
  45. breakscope-0.1.0/tests/contracts/rules/parameter.removed/old.yaml +82 -0
  46. breakscope-0.1.0/tests/contracts/rules/parameter.type.changed/new.yaml +79 -0
  47. breakscope-0.1.0/tests/contracts/rules/parameter.type.changed/old.yaml +82 -0
  48. breakscope-0.1.0/tests/contracts/rules/request.body.added.required/new.yaml +83 -0
  49. breakscope-0.1.0/tests/contracts/rules/request.body.added.required/old.yaml +77 -0
  50. breakscope-0.1.0/tests/contracts/rules/request.body.became_required/new.yaml +83 -0
  51. breakscope-0.1.0/tests/contracts/rules/request.body.became_required/old.yaml +82 -0
  52. breakscope-0.1.0/tests/contracts/rules/request.media_type.removed/new.yaml +82 -0
  53. breakscope-0.1.0/tests/contracts/rules/request.media_type.removed/old.yaml +82 -0
  54. breakscope-0.1.0/tests/contracts/rules/request.property.added.required/new.yaml +86 -0
  55. breakscope-0.1.0/tests/contracts/rules/request.property.added.required/old.yaml +82 -0
  56. breakscope-0.1.0/tests/contracts/rules/request.property.became_required/new.yaml +84 -0
  57. breakscope-0.1.0/tests/contracts/rules/request.property.became_required/old.yaml +82 -0
  58. breakscope-0.1.0/tests/contracts/rules/request.property.enum.value_removed/new.yaml +81 -0
  59. breakscope-0.1.0/tests/contracts/rules/request.property.enum.value_removed/old.yaml +82 -0
  60. breakscope-0.1.0/tests/contracts/rules/request.property.removed/new.yaml +80 -0
  61. breakscope-0.1.0/tests/contracts/rules/request.property.removed/old.yaml +82 -0
  62. breakscope-0.1.0/tests/contracts/rules/request.property.type.changed/new.yaml +82 -0
  63. breakscope-0.1.0/tests/contracts/rules/request.property.type.changed/old.yaml +82 -0
  64. breakscope-0.1.0/tests/contracts/rules/response.enum.value_added/new.yaml +83 -0
  65. breakscope-0.1.0/tests/contracts/rules/response.enum.value_added/old.yaml +82 -0
  66. breakscope-0.1.0/tests/contracts/rules/response.enum.value_removed/new.yaml +81 -0
  67. breakscope-0.1.0/tests/contracts/rules/response.enum.value_removed/old.yaml +82 -0
  68. breakscope-0.1.0/tests/contracts/rules/response.media_type.removed/new.yaml +82 -0
  69. breakscope-0.1.0/tests/contracts/rules/response.media_type.removed/old.yaml +82 -0
  70. breakscope-0.1.0/tests/contracts/rules/response.property.added/new.yaml +84 -0
  71. breakscope-0.1.0/tests/contracts/rules/response.property.added/old.yaml +82 -0
  72. breakscope-0.1.0/tests/contracts/rules/response.property.became_nullable/new.yaml +83 -0
  73. breakscope-0.1.0/tests/contracts/rules/response.property.became_nullable/old.yaml +82 -0
  74. breakscope-0.1.0/tests/contracts/rules/response.property.became_optional/new.yaml +81 -0
  75. breakscope-0.1.0/tests/contracts/rules/response.property.became_optional/old.yaml +82 -0
  76. breakscope-0.1.0/tests/contracts/rules/response.property.format.changed/new.yaml +82 -0
  77. breakscope-0.1.0/tests/contracts/rules/response.property.format.changed/old.yaml +82 -0
  78. breakscope-0.1.0/tests/contracts/rules/response.property.removed/new.yaml +80 -0
  79. breakscope-0.1.0/tests/contracts/rules/response.property.removed/old.yaml +82 -0
  80. breakscope-0.1.0/tests/contracts/rules/response.property.type.changed/new.yaml +82 -0
  81. breakscope-0.1.0/tests/contracts/rules/response.property.type.changed/old.yaml +82 -0
  82. breakscope-0.1.0/tests/contracts/rules/response.status.removed/new.yaml +82 -0
  83. breakscope-0.1.0/tests/contracts/rules/response.status.removed/old.yaml +82 -0
  84. breakscope-0.1.0/tests/contracts/rules/schema.union.changed/new.yaml +82 -0
  85. breakscope-0.1.0/tests/contracts/rules/schema.union.changed/old.yaml +82 -0
  86. breakscope-0.1.0/tests/contracts/test_contracts.py +226 -0
  87. breakscope-0.1.0/tests/contracts/test_rules.py +32 -0
  88. breakscope-0.1.0/tests/test_cli.py +65 -0
  89. breakscope-0.0.1/README.md +0 -17
  90. breakscope-0.0.1/breakscope/cli/main.py +0 -44
  91. breakscope-0.0.1/recoverycode.md +0 -9
  92. breakscope-0.0.1/tests/test_cli.py +0 -16
  93. {breakscope-0.0.1 → breakscope-0.1.0}/.github/workflows/tests.yml +0 -0
  94. {breakscope-0.0.1 → breakscope-0.1.0}/breakscope/__main__.py +0 -0
  95. {breakscope-0.0.1 → breakscope-0.1.0}/breakscope/cli/__init__.py +0 -0
  96. {breakscope-0.0.1 → breakscope-0.1.0}/pyproject.toml +0 -0
  97. {breakscope-0.0.1 → breakscope-0.1.0}/tests/__init__.py +0 -0
@@ -0,0 +1,43 @@
1
+ name: release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - name: Check tag matches package version
18
+ run: |
19
+ version=$(python -c "import re; print(re.search(r'__version__ = \"(.+)\"', open('breakscope/__init__.py').read()).group(1))")
20
+ if [ "v$version" != "$GITHUB_REF_NAME" ]; then
21
+ echo "::error::Tag $GITHUB_REF_NAME does not match breakscope/__init__.py version $version"
22
+ exit 1
23
+ fi
24
+ - run: python -m pip install build twine
25
+ - run: python -m build
26
+ - run: python -m twine check dist/*
27
+ - uses: actions/upload-artifact@v4
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ publish:
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ environment: pypi
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -6,3 +6,4 @@ build/
6
6
  .mypy_cache/
7
7
  .ruff_cache/
8
8
  .pytest_cache/
9
+ recoverycode.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: breakscope
3
- Version: 0.0.1
3
+ Version: 0.1.0
4
4
  Summary: See what your API changes will break in your code.
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -28,7 +28,25 @@ pip install breakscope
28
28
  breakscope diff openapi-old.yaml openapi-new.yaml
29
29
  ```
30
30
 
31
- **Status:** pre-alpha. See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/ARCHITECTURE-v0.1.md](docs/ARCHITECTURE-v0.1.md).
31
+ ```text
32
+ BREAKING CHANGES: 4
33
+
34
+ 🔴 response.property.removed User.name
35
+ A response property was removed.
36
+ affects 3 operations:
37
+ GET /users -> 200
38
+ POST /users -> 201
39
+ GET /users/{userId} -> 200
40
+
41
+ 🔴 request.property.became_required POST /users
42
+ [request] `email` is now required
43
+ ...
44
+ ```
45
+
46
+ Try it on the demo: `breakscope diff examples/demo/api/openapi-v1.yaml examples/demo/api/openapi-v2.yaml`.
47
+ Use `--format json` for machine-readable output. Exit codes: `0` no breaking changes, `1` breaking changes, `2` error.
48
+
49
+ **Status:** v0.1: contract diff. Tracing changes into your code comes in v0.4. See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/ARCHITECTURE-v0.1.md](docs/ARCHITECTURE-v0.1.md).
32
50
 
33
51
  ## License
34
52
 
@@ -0,0 +1,35 @@
1
+ # BreakScope
2
+
3
+ > **See what your API changes will break — before they reach production.**
4
+
5
+ API contract changes are easy to detect. Knowing what they break isn't.
6
+ BreakScope diffs your OpenAPI specs and traces each breaking change to the exact `file:line` in your Python and TypeScript code that's likely affected.
7
+
8
+ ```bash
9
+ pip install breakscope
10
+ breakscope diff openapi-old.yaml openapi-new.yaml
11
+ ```
12
+
13
+ ```text
14
+ BREAKING CHANGES: 4
15
+
16
+ 🔴 response.property.removed User.name
17
+ A response property was removed.
18
+ affects 3 operations:
19
+ GET /users -> 200
20
+ POST /users -> 201
21
+ GET /users/{userId} -> 200
22
+
23
+ 🔴 request.property.became_required POST /users
24
+ [request] `email` is now required
25
+ ...
26
+ ```
27
+
28
+ Try it on the demo: `breakscope diff examples/demo/api/openapi-v1.yaml examples/demo/api/openapi-v2.yaml`.
29
+ Use `--format json` for machine-readable output. Exit codes: `0` no breaking changes, `1` breaking changes, `2` error.
30
+
31
+ **Status:** v0.1: contract diff. Tracing changes into your code comes in v0.4. See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/ARCHITECTURE-v0.1.md](docs/ARCHITECTURE-v0.1.md).
32
+
33
+ ## License
34
+
35
+ MIT
@@ -1,3 +1,3 @@
1
1
  """BreakScope: see what your API changes will break in your code."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.1.0"
@@ -0,0 +1,93 @@
1
+ """The change model shared by the diff engine, reporters and (later) the impact engine.
2
+
3
+ Nothing in here knows about OpenAPI. Downstream code depends on this module only.
4
+ """
5
+
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+
12
+ class Severity(StrEnum):
13
+ BREAKING = "breaking"
14
+ WARNING = "warning"
15
+ INFO = "info"
16
+
17
+ @property
18
+ def rank(self) -> int:
19
+ return {"breaking": 0, "warning": 1, "info": 2}[self.value]
20
+
21
+
22
+ class Direction(StrEnum):
23
+ REQUEST = "request"
24
+ RESPONSE = "response"
25
+
26
+
27
+ class APIChange(BaseModel):
28
+ model_config = ConfigDict(frozen=True)
29
+
30
+ rule: str
31
+ severity: Severity
32
+ method: str | None = None
33
+ path: str | None = None
34
+ direction: Direction | None = None
35
+ status_code: str | None = None
36
+ # Location of the field from the root of the request/response body (or parameter).
37
+ # "[]" marks an array element: ("items", "[]", "name").
38
+ field_path: tuple[str, ...] = ()
39
+ # Nearest named schema (components/schemas/<name>) that owns the field, and the
40
+ # field's path relative to it. User.name is ("name",) whether it was reached
41
+ # through GET /users/{id} or through GET /users -> "[]".
42
+ schema_name: str | None = None
43
+ schema_path: tuple[str, ...] = ()
44
+ old: Any = None
45
+ new: Any = None
46
+ message: str
47
+
48
+ @property
49
+ def key(self) -> str:
50
+ """Stable identifier for snapshots, dedupe and ignore lists."""
51
+ parts = [
52
+ self.rule,
53
+ self.method or "",
54
+ self.path or "",
55
+ self.status_code or "",
56
+ ".".join(self.field_path),
57
+ ]
58
+ return " ".join(p for p in parts if p)
59
+
60
+ @property
61
+ def group_key(self) -> tuple[str, ...]:
62
+ """Changes to one shared schema field group together across operations."""
63
+ if self.schema_name and self.schema_path:
64
+ return (self.rule, self.schema_name, *self.schema_path)
65
+ return (self.key,)
66
+
67
+ @property
68
+ def subject(self) -> str:
69
+ """Short human label for the changed thing: `User.name`, `email`, `items[].id`."""
70
+ if self.schema_name and self.schema_path:
71
+ return format_field_path((self.schema_name, *self.schema_path))
72
+ return format_field_path(self.field_path)
73
+
74
+ def sort_key(self) -> tuple[Any, ...]:
75
+ return (
76
+ self.severity.rank,
77
+ self.path or "",
78
+ self.method or "",
79
+ self.direction or "",
80
+ self.status_code or "",
81
+ self.field_path,
82
+ self.rule,
83
+ )
84
+
85
+
86
+ def format_field_path(path: tuple[str, ...]) -> str:
87
+ out = ""
88
+ for part in path:
89
+ if part == "[]":
90
+ out += "[]"
91
+ else:
92
+ out += f".{part}" if out else part
93
+ return out
@@ -0,0 +1,94 @@
1
+ from enum import StrEnum
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from breakscope import __version__
9
+ from breakscope.changes import Severity
10
+ from breakscope.contracts import diff_files
11
+ from breakscope.errors import BreakScopeError
12
+ from breakscope.reports import json as json_report
13
+ from breakscope.reports import terminal
14
+
15
+ app = typer.Typer(
16
+ name="breakscope",
17
+ help="See what your API changes will break in your code.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ EXIT_OK, EXIT_BREAKING, EXIT_ERROR = 0, 1, 2
22
+
23
+
24
+ class Format(StrEnum):
25
+ terminal = "terminal"
26
+ json = "json"
27
+
28
+
29
+ def _version(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"breakscope {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ @app.callback()
36
+ def main(
37
+ version: Annotated[
38
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
39
+ ] = False,
40
+ ) -> None:
41
+ pass
42
+
43
+
44
+ @app.command()
45
+ def diff(
46
+ old: Annotated[Path, typer.Argument(help="Old OpenAPI spec (YAML or JSON).")],
47
+ new: Annotated[Path, typer.Argument(help="New OpenAPI spec (YAML or JSON).")],
48
+ fmt: Annotated[Format, typer.Option("--format", "-f", help="Output format.")] = Format.terminal,
49
+ min_severity: Annotated[
50
+ Severity, typer.Option("--min-severity", help="Hide changes below this severity.")
51
+ ] = Severity.WARNING,
52
+ output: Annotated[
53
+ Path | None, typer.Option("--output", "-o", help="Write the report to a file.")
54
+ ] = None,
55
+ ) -> None:
56
+ """Show contract changes between two OpenAPI specs.
57
+
58
+ Exit code 0: no breaking changes. 1: breaking changes found. 2: error.
59
+ """
60
+ err = Console(stderr=True)
61
+ try:
62
+ changes, a, b = diff_files(old, new)
63
+ except BreakScopeError as e:
64
+ err.print(e.render(), markup=False, highlight=False)
65
+ raise typer.Exit(EXIT_ERROR) from None
66
+
67
+ skipped = {
68
+ f"{m} {p} ({src})": reason
69
+ for contract, src in ((a, "old"), (b, "new"))
70
+ for (m, p), reason in contract.skipped.items()
71
+ }
72
+ shown = [c for c in changes if c.severity.rank <= min_severity.rank]
73
+
74
+ if fmt is Format.json:
75
+ text = json_report.render(shown, skipped=skipped)
76
+ if output:
77
+ output.write_text(text, encoding="utf-8")
78
+ else:
79
+ typer.echo(text, nl=False)
80
+ elif output:
81
+ with output.open("w", encoding="utf-8") as fh:
82
+ terminal.render(shown, Console(file=fh, width=100, no_color=True), skipped=skipped)
83
+ else:
84
+ terminal.render(shown, Console(highlight=False), skipped=skipped)
85
+
86
+ breaking = any(c.severity is Severity.BREAKING for c in changes)
87
+ raise typer.Exit(EXIT_BREAKING if breaking else EXIT_OK)
88
+
89
+
90
+ @app.command()
91
+ def analyze() -> None:
92
+ """Trace API changes into your codebase."""
93
+ typer.echo("analyze: coming in v0.4", err=True)
94
+ raise typer.Exit(EXIT_ERROR)
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+
3
+ from breakscope.changes import APIChange
4
+ from breakscope.contracts.diff import diff_contracts
5
+ from breakscope.contracts.loader import load_spec
6
+ from breakscope.contracts.model import Contract
7
+ from breakscope.contracts.normalize import normalize
8
+
9
+
10
+ def load_contract(path: Path) -> Contract:
11
+ return normalize(load_spec(path))
12
+
13
+
14
+ def diff_files(old: Path, new: Path) -> tuple[list[APIChange], Contract, Contract]:
15
+ a, b = load_contract(old), load_contract(new)
16
+ return diff_contracts(a, b), a, b
17
+
18
+
19
+ __all__ = ["Contract", "diff_contracts", "diff_files", "load_contract"]