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 <noreply@anthropic.com>
This commit is contained in:
sahilmalhotra1987 2026-04-19 12:11:48 +05:30
parent 2ac89889f4
commit 9536b1aeef
3 changed files with 120 additions and 5 deletions

View file

@ -3,6 +3,8 @@ Version 3.2.0
Unreleased 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` - Drop support for Python 3.9. :pr:`5730`
- Remove previously deprecated code: ``__version__``. :pr:`5648` - Remove previously deprecated code: ``__version__``. :pr:`5648`
- ``RequestContext`` has merged with ``AppContext``. ``RequestContext`` is now - ``RequestContext`` has merged with ``AppContext``. ``RequestContext`` is now

View file

@ -4,6 +4,7 @@ import ast
import collections.abc as cabc import collections.abc as cabc
import importlib.metadata import importlib.metadata
import inspect import inspect
import json
import os import os
import platform import platform
import re 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("--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 @with_appcontext
def routes_command(sort: str, all_methods: bool) -> None: def routes_command(sort: str, all_methods: bool, output_json: bool) -> None:
"""Show all registered routes with endpoints and methods.""" """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()) 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: if not rules:
click.echo("No routes were registered.") click.echo("No routes were registered.")
return 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 = [] rows = []
for rule in rules: for rule in rules:

View file

@ -1,6 +1,7 @@
# This file was part of Flask-CLI and was modified under the terms of # This file was part of Flask-CLI and was modified under the terms of
# its Revised BSD License. Copyright © 2015 CERN. # its Revised BSD License. Copyright © 2015 CERN.
import importlib.metadata import importlib.metadata
import json
import os import os
import platform import platform
import ssl import ssl
@ -518,6 +519,78 @@ class TestRoutes:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Host" in result.output 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(): def dotenv_not_available():
try: try: