diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..b88fd2f --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,110 @@ +name: Documentation + +# The keyword documentation is generated from the library itself and published to the +# gh-pages branch, one directory per released version plus /dev for the current main. +# +# It is not committed to the repository. A rendered libdoc page carries the generation +# time, the absolute path of the machine that produced it and the Robot Framework and +# Python versions used, so a committed copy cannot be compared against a fresh one +# without normalising all of that away - which was tried, was fragile, and failed three +# release attempts before being removed. Generating on publish means there is nothing +# that can drift. + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # Publishing rewrites a shared branch, so two runs must not do it at once. + group: docs + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + # Only this job writes, and only to the gh-pages branch. + contents: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Install + run: poetry install + + - name: Work out what is being published + id: target + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "path=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "path=dev" >> "$GITHUB_OUTPUT" + echo "release=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate the keyword documentation + run: poetry run python -m robot.libdoc MitmLibrary MitmLibraryKeywords.html + + - name: Check out the published site + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git ls-remote --exit-code --heads origin gh-pages; then + git fetch origin gh-pages --depth 1 + git worktree add site origin/gh-pages + else + # First publish: start the branch with no history of its own. + git worktree add --detach site + git -C site checkout --orphan gh-pages + git -C site rm -rf . --quiet || true + fi + + - name: Add this version to the site + run: | + set -eu + path="${{ steps.target.outputs.path }}" + mkdir -p "site/${path}" + cp MitmLibraryKeywords.html "site/${path}/MitmLibraryKeywords.html" + + if [ "${{ steps.target.outputs.release }}" = "true" ]; then + # The bare path is what pyproject.toml, the README and the metadata of every + # already published release point at, so it stays as the newest release. + cp MitmLibraryKeywords.html site/MitmLibraryKeywords.html + mkdir -p site/latest + cp MitmLibraryKeywords.html site/latest/MitmLibraryKeywords.html + fi + + # GitHub Pages otherwise runs the site through Jekyll, which drops directories + # whose names begin with an underscore and needs no help here regardless. + touch site/.nojekyll + + if [ "${{ steps.target.outputs.release }}" = "true" ]; then + poetry run python tools/build_docs_index.py \ + site/versions.json site/index.html --add "$path" --release + else + poetry run python tools/build_docs_index.py \ + site/versions.json site/index.html --add "$path" + fi + + - name: Publish + run: | + cd site + git add -A + if git diff --cached --quiet; then + echo "The published documentation is already up to date." + exit 0 + fi + git commit -m "docs: publish ${{ steps.target.outputs.path }} from ${GITHUB_SHA}" + git push origin HEAD:gh-pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d83ceeb..b30aac8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,11 +9,12 @@ jobs: runs-on: ubuntu-latest environment: pypi permissions: - # Declaring any permission replaces the whole default set, so contents: read has - # to be named too. Without it the token cannot clone a private repository and - # checkout fails with "repository not found", which reads like a wrong URL rather - # than a missing scope. - contents: read + # Declaring any permission replaces the whole default set, so contents has to be + # named too. Without it the token cannot clone a private repository and checkout + # fails with "repository not found", which reads like a wrong URL rather than a + # missing scope. It is write rather than read because this job also creates the + # GitHub release for the tag. + contents: write # Trusted publishing, so no token is stored in the repository. id-token: write steps: @@ -38,4 +39,38 @@ jobs: - name: Build run: poetry build + - name: Take the release notes from the changelog + # A release with no notes is a release nobody can read, and the changelog already + # says what changed. This lifts the section for this version out of it. + run: | + version="${GITHUB_REF_NAME#v}" + python - "$version" <<'PY' > release-notes.md + import re, sys + from pathlib import Path + + version = sys.argv[1] + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + # Everything between this version's heading and the next version heading. + match = re.search( + rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)", + changelog, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise SystemExit(f"CHANGELOG.md has no section for {version}.") + print(match.group(1).strip()) + PY + - uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + # After publishing, so a release is never announced for a version that failed to + # reach PyPI. + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "${GITHUB_REF_NAME#v}" \ + --notes-file release-notes.md \ + --verify-tag \ + dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b2e7e..80a6939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ promise in the README. ## [Unreleased] +Nothing yet. + +## [1.0.0] - 2026-08-18 + +1.0.0 reworks the keyword surface once, deliberately, and then freezes it: from this +release onwards keyword names, argument names and their order will not change within 1.x. +See "API stability" in the README. + ### Changed - breaking Every kind of manipulation used to have its own list, its own remove keyword and its own @@ -125,4 +133,5 @@ Two behaviour changes come with it: module that reaches into mitmproxy's internals. This is internal, but it does move `MitmLibrary.STARTUP_TIMEOUT` and `MitmLibrary.SHUTDOWN_TIMEOUT` to that module. -[Unreleased]: https://github.com/MobyNL/robotframework-mitmlibrary/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/MobyNL/robotframework-mitmlibrary/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/MobyNL/robotframework-mitmlibrary/compare/v0.3.0...v1.0.0 diff --git a/README.md b/README.md index ddccfa9..c5906b0 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,19 @@ If you need help, have suggestions or want to discuss anything, feel free to con ## Features -- Interact with MITM proxy using Robot Framework keywords. -- Manipulate network traffic for testing purposes. -- Easily simulate different network conditions and responses. -- Integrate MITM proxy capabilities into your existing Robot Framework tests. +- **Change what comes back.** Replace a response, or only its status, headers or body, + for requests matching a url, a method, or a regular expression. +- **Change what goes out.** Add or remove request headers, replace a request body, or + send a request to a different url or host entirely. +- **Assert on what was sent.** Record the traffic that passed through and ask whether a + request was made, how often, and with what — the half of testing a proxy is usually not + used for. +- **Break things on purpose.** Block a request, drop a connection, hold a request until + the client gives up, or cut a response short while it still claims its full length. +- **Sit wherever the traffic is.** A forward proxy by default, or in front of a service, + or chained through the network's own proxy. + +Every rule is addressed by an alias, matched the same way, and removed the same way. ## Installation @@ -26,6 +35,8 @@ If you need help, have suggestions or want to discuss anything, feel free to con pip install robotframework-mitmlibrary ``` +Requires Python 3.12 or newer, which is mitmproxy's own floor. + ## Usage @@ -165,17 +176,52 @@ mitm. Follow the guide on the [Mitm website](https://docs.mitmproxy.org/stable/concepts-certificates/) ## Documentation -For detailed information on the available keywords and usage examples, please refer to the [Keyword Documentation](https://mobynl.github.io/robotframework-mitmlibrary/MitmLibraryKeywords.html) + +The [keyword documentation](https://mobynl.github.io/robotframework-mitmlibrary/MitmLibraryKeywords.html) +describes every keyword, its arguments and examples. + +It is published per version, so you can read the documentation for the version you +actually have installed rather than for whatever is newest: + +- [latest release](https://mobynl.github.io/robotframework-mitmlibrary/latest/MitmLibraryKeywords.html) +- [all versions](https://mobynl.github.io/robotframework-mitmlibrary/) +- [current main, unreleased](https://mobynl.github.io/robotframework-mitmlibrary/dev/MitmLibraryKeywords.html) ## API stability -The keyword surface is **not yet stable**. Keyword names, argument names and their order -may still change while the library is on 0.x, and the [CHANGELOG](CHANGELOG.md) records -those changes. +From 1.0.0 onwards the keyword surface is stable: + +- Keyword names, argument names and their order will not change in a 1.x release. +- New arguments are only ever added at the end, with defaults, so existing calls keep + working whether they pass arguments positionally or by name. +- The rule model is part of that promise, not just the signatures: how patterns are + matched, the order in which several matching rules are applied, and what `times` means + will not change either. + +Anything not listed above is internal and may change: module layout, class names, and +everything with a leading underscore. Import keywords through Robot Framework rather than +calling into the package directly, and none of that will reach you. + +Breaking changes wait for 2.0 and are recorded in the [CHANGELOG](CHANGELOG.md). + +### Migrating from 0.3.0 + +1.0.0 reworked the keywords once so that every kind of rule is addressed the same way. +The [CHANGELOG](CHANGELOG.md) has the full table; in short: + +| Before | Now | +| --- | --- | +| `Add To Blocklist url` | `Block Requests alias url` | +| `Add Custom Response alias url overwrite_headers= overwrite_body=` | `Set Response alias url headers= body=` | +| `Add Custom Response Status Code` | `Set Response Status` | +| `Remove Url From Blocklist`, `Remove Custom Response`, `Remove Custom Status Code` | `Remove Rule alias` | +| `Clear All Proxy Items` | `Clear All Rules` | +| the four `Log ...` keywords | `Log Proxy Rules` | -1.0.0 fixes the surface: it reworks the keywords once, deliberately and with a documented -migration path, and from that release onwards names and argument order will not change -within 1.x. +Two behaviour changes come with it: a blocked request is answered with `403` rather than +having its connection dropped (`mode=RESET` restores the old behaviour), and when several +rules match one request all of them apply, in a defined order, instead of the last one +silently winning. ## Contributing Contributions are welcome! If you encounter any issues, have suggestions for improvements, or would like to add new features, feel free to open an issue or submit a pull request. diff --git a/docs/MitmLibraryKeywords.html b/docs/MitmLibraryKeywords.html deleted file mode 100644 index 019953b..0000000 --- a/docs/MitmLibraryKeywords.html +++ /dev/null @@ -1,387 +0,0 @@ - - - - - - - - - - - - - -
-

Opening library documentation failed

- -
- - - - - - - - -
- - - - - - - - - - - - - - - - diff --git a/pyproject.toml b/pyproject.toml index a50ee2c..c30306a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "robotframework-mitmlibrary" -version = "0.3.0" +version = "1.0.0" description = "A Robot Framework library for managing and manipulating HTTP requests and responses using a man-in-the-middle proxy approach. It allows blocking requests, customizing responses, adding delays, and more." authors = ["Mark "] license = "MIT" @@ -18,7 +18,7 @@ keywords = [ "maninthemiddle", ] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Framework :: Robot Framework :: Library", "Intended Audience :: Developers", "Topic :: Software Development :: Testing", diff --git a/tests/test_docs_index.py b/tests/test_docs_index.py new file mode 100644 index 0000000..05971a5 --- /dev/null +++ b/tests/test_docs_index.py @@ -0,0 +1,137 @@ +"""Tests for the tool that builds the published documentation index. + +This code runs once per release, in a workflow, where a mistake is only noticed after the +fact and shows up as a broken or misleading documentation site. That is a good reason to +test it here rather than by publishing and looking. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) + +import build_docs_index # noqa: E402 - needs the path above + + +class TestUpdateVersions(unittest.TestCase): + def test_the_first_release_becomes_the_latest(self): + versions = build_docs_index.update_versions([], "1.0.0", is_release=True) + self.assertEqual(versions[0]["version"], "1.0.0") + self.assertEqual(versions[0]["tags"], ["latest"]) + + def test_a_newer_release_takes_the_latest_tag_over(self): + versions = build_docs_index.update_versions([], "1.0.0", is_release=True) + versions = build_docs_index.update_versions(versions, "1.1.0", is_release=True) + tags = {version["path"]: version["tags"] for version in versions} + self.assertEqual(tags["1.1.0"], ["latest"]) + self.assertEqual(tags["1.0.0"], []) + + def test_an_older_release_published_late_does_not_steal_latest(self): + """A patch to an old line is still an older version than the newest one.""" + versions = build_docs_index.update_versions([], "1.1.0", is_release=True) + versions = build_docs_index.update_versions(versions, "1.0.1", is_release=True) + tags = {version["path"]: version["tags"] for version in versions} + self.assertEqual(tags["1.1.0"], ["latest"]) + self.assertEqual(tags["1.0.1"], []) + + def test_versions_are_compared_as_numbers_not_as_text(self): + """As text, '1.10.0' sorts before '1.9.0', which would be wrong.""" + versions = build_docs_index.update_versions([], "1.9.0", is_release=True) + versions = build_docs_index.update_versions(versions, "1.10.0", is_release=True) + tags = {version["path"]: version["tags"] for version in versions} + self.assertEqual(tags["1.10.0"], ["latest"]) + self.assertEqual(tags["1.9.0"], []) + + def test_the_development_build_is_never_the_latest(self): + versions = build_docs_index.update_versions([], "1.0.0", is_release=True) + versions = build_docs_index.update_versions(versions, "dev", is_release=False) + tags = {version["path"]: version["tags"] for version in versions} + self.assertEqual(tags["dev"], ["unreleased"]) + self.assertEqual(tags["1.0.0"], ["latest"]) + + def test_publishing_the_same_path_twice_replaces_its_entry(self): + """main is published on every push, and must not accumulate entries.""" + versions = build_docs_index.update_versions([], "dev", is_release=False) + versions = build_docs_index.update_versions(versions, "dev", is_release=False) + self.assertEqual(len(versions), 1) + + def test_a_release_published_twice_replaces_its_entry(self): + versions = build_docs_index.update_versions([], "1.0.0", is_release=True) + versions = build_docs_index.update_versions(versions, "1.0.0", is_release=True) + self.assertEqual(len(versions), 1) + self.assertEqual(versions[0]["tags"], ["latest"]) + + +class TestRendering(unittest.TestCase): + def test_versions_are_listed_newest_first_with_development_above_them(self): + versions = [ + {"version": "1.0.0", "path": "1.0.0", "tags": []}, + {"version": "main (unreleased)", "path": "dev", "tags": ["unreleased"]}, + {"version": "1.1.0", "path": "1.1.0", "tags": ["latest"]}, + ] + versions.sort(key=build_docs_index._sort_key) + self.assertEqual( + [version["path"] for version in versions], ["dev", "1.1.0", "1.0.0"] + ) + + def test_each_version_links_to_its_own_page(self): + page = build_docs_index.render( + [{"version": "1.0.0", "path": "1.0.0", "tags": ["latest"]}] + ) + self.assertIn('href="1.0.0/MitmLibraryKeywords.html"', page) + self.assertIn("latest", page) + + def test_an_empty_site_says_so_rather_than_rendering_nothing(self): + self.assertIn("No documentation has been published", build_docs_index.render([])) + + def test_the_page_explains_why_older_versions_are_missing(self): + """A gap with no explanation reads as something broken.""" + page = build_docs_index.render([]) + self.assertIn("before 1.0.0 are not published", page) + + def test_version_names_are_escaped(self): + """The name comes from a tag, and a tag can contain anything.""" + page = build_docs_index.render( + [{"version": "