From 9536b1aeefcd8178f7102cbd68c43a4ed57b183f Mon Sep 17 00:00:00 2001 From: sahilmalhotra1987 Date: Sun, 19 Apr 2026 12:11:48 +0530 Subject: [PATCH] feat(cli): add --json flag to flask routes command Adds a machine-readable output mode to `flask routes`. When --json is passed, the route table is emitted as a JSON array instead of the text table. Each entry has `endpoint`, `methods` (sorted list), and `rule`; subdomain apps also emit `subdomain`, host-matching apps emit `host`. HEAD/OPTIONS are filtered unless --all-methods is also passed. Empty apps produce `[]`. The --sort flag is honoured in JSON mode. Backwards compatible: the flag is optional and all existing invocations of `flask routes` are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- CHANGES.rst | 2 ++ src/flask/cli.py | 50 ++++++++++++++++++++++++++++---- tests/test_cli.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a5fa63f1..31abd755 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,8 @@ Version 3.2.0 Unreleased +- The ``flask routes`` command has a ``--json`` flag to output the route + table as a JSON array, suitable for scripting and tooling. - Drop support for Python 3.9. :pr:`5730` - Remove previously deprecated code: ``__version__``. :pr:`5648` - ``RequestContext`` has merged with ``AppContext``. ``RequestContext`` is now diff --git a/src/flask/cli.py b/src/flask/cli.py index 3fa65cfd..68f50dca 100644 --- a/src/flask/cli.py +++ b/src/flask/cli.py @@ -4,6 +4,7 @@ import ast import collections.abc as cabc import importlib.metadata import inspect +import json import os import platform import re @@ -1057,18 +1058,57 @@ def shell_command() -> None: ), ) @click.option("--all-methods", is_flag=True, help="Show HEAD and OPTIONS methods.") +@click.option("--json", "output_json", is_flag=True, help="Output routes as JSON.") @with_appcontext -def routes_command(sort: str, all_methods: bool) -> None: - """Show all registered routes with endpoints and methods.""" +def routes_command(sort: str, all_methods: bool, output_json: bool) -> None: + """Show all registered routes with endpoints and methods. + + :param sort: Column to sort by. + :param all_methods: Include HEAD and OPTIONS in the methods column. + :param output_json: Emit JSON instead of a text table. + + .. versionchanged:: 3.2 + Added the ``--json`` flag. + """ rules = list(current_app.url_map.iter_rules()) + ignored_methods = set() if all_methods else {"HEAD", "OPTIONS"} + host_matching = current_app.url_map.host_matching + has_domain = any(rule.host if host_matching else rule.subdomain for rule in rules) + + if output_json: + data: list[dict[str, t.Any]] = [] + + for rule in rules: + entry: dict[str, t.Any] = { + "endpoint": rule.endpoint, + "methods": sorted((rule.methods or set()) - ignored_methods), + "rule": rule.rule, + } + + if has_domain: + if host_matching: + entry["host"] = rule.host or "" + else: + entry["subdomain"] = rule.subdomain or "" + + data.append(entry) + + sort_key_map: dict[str, t.Callable[[dict[str, t.Any]], str]] = { + "endpoint": lambda e: e["endpoint"], + "methods": lambda e: ", ".join(e["methods"]), + "domain": lambda e: e.get("host", e.get("subdomain", "")), + "rule": lambda e: e["rule"], + } + if sort in sort_key_map: + data.sort(key=sort_key_map[sort]) + + click.echo(json.dumps(data, indent=2)) + return if not rules: click.echo("No routes were registered.") return - ignored_methods = set() if all_methods else {"HEAD", "OPTIONS"} - host_matching = current_app.url_map.host_matching - has_domain = any(rule.host if host_matching else rule.subdomain for rule in rules) rows = [] for rule in rules: diff --git a/tests/test_cli.py b/tests/test_cli.py index 2a34088b..a5911d49 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ # This file was part of Flask-CLI and was modified under the terms of # its Revised BSD License. Copyright © 2015 CERN. import importlib.metadata +import json import os import platform import ssl @@ -518,6 +519,78 @@ class TestRoutes: assert result.exit_code == 0 assert "Host" in result.output + def test_json_output(self, invoke): + result = invoke(["routes", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert isinstance(data, list) + endpoints = {r["endpoint"] for r in data} + assert "yyy_get_post" in endpoints + assert "aaa_post" in endpoints + for route in data: + assert "endpoint" in route + assert "methods" in route + assert "rule" in route + assert isinstance(route["methods"], list) + + def test_json_no_routes(self, runner): + app = Flask(__name__, static_folder=None) + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == [] + + def test_json_excludes_head_options_by_default(self, invoke): + result = invoke(["routes", "--json"]) + data = json.loads(result.output) + for route in data: + assert "HEAD" not in route["methods"] + assert "OPTIONS" not in route["methods"] + + def test_json_all_methods(self, invoke): + result = invoke(["routes", "--json", "--all-methods"]) + data = json.loads(result.output) + get_post_route = next(r for r in data if r["endpoint"] == "yyy_get_post") + assert "HEAD" in get_post_route["methods"] + assert "OPTIONS" in get_post_route["methods"] + + def test_json_with_subdomain(self, runner): + app = Flask(__name__, static_folder=None) + app.add_url_rule("/a", subdomain="a", endpoint="a") + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + route_a = next(r for r in data if r["endpoint"] == "a") + assert route_a["subdomain"] == "a" + + def test_json_with_host_matching(self, runner): + app = Flask(__name__, static_folder=None) + app.url_map.host_matching = True + app.add_url_rule("/a", host="example.com", endpoint="a") + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + route_a = next(r for r in data if r["endpoint"] == "a") + assert route_a["host"] == "example.com" + + @pytest.mark.parametrize("sort_key,extract", [ + ("endpoint", lambda r: r["endpoint"]), + ("rule", lambda r: r["rule"]), + ]) + def test_json_sort(self, invoke, sort_key, extract): + result = invoke(["routes", "--json", "-s", sort_key]) + data = json.loads(result.output) + values = [extract(r) for r in data] + assert values == sorted(values) + + def test_json_sort_match_preserves_iter_rules_order(self, app, invoke): + match_result = invoke(["routes", "--json", "-s", "match"]) + data = json.loads(match_result.output) + expected_order = [rule.endpoint for rule in app.url_map.iter_rules()] + assert [r["endpoint"] for r in data] == expected_order + def dotenv_not_available(): try: