diff --git a/CHANGES.rst b/CHANGES.rst index e75719ee..ddd3f8cc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -44,6 +44,8 @@ Unreleased to set the domain, which modern browsers interpret as an exact match rather than a subdomain match. Warnings about ``localhost`` and IP addresses are also removed. :issue:`5051` +- The ``routes`` command shows each rule's ``subdomain`` or ``host`` when domain + matching is in use. :issue:`5004` Version 2.2.4 diff --git a/src/flask/cli.py b/src/flask/cli.py index 37a15ff2..6cc36219 100644 --- a/src/flask/cli.py +++ b/src/flask/cli.py @@ -9,7 +9,7 @@ import sys import traceback import typing as t from functools import update_wrapper -from operator import attrgetter +from operator import itemgetter import click from click.core import ParameterSource @@ -989,49 +989,62 @@ def shell_command() -> None: @click.option( "--sort", "-s", - type=click.Choice(("endpoint", "methods", "rule", "match")), + type=click.Choice(("endpoint", "methods", "domain", "rule", "match")), default="endpoint", help=( - 'Method to sort routes by. "match" is the order that Flask will match ' - "routes when dispatching a request." + "Method to sort routes by. 'match' is the order that Flask will match routes" + " when dispatching a request." ), ) @click.option("--all-methods", is_flag=True, help="Show HEAD and OPTIONS methods.") @with_appcontext def routes_command(sort: str, all_methods: bool) -> None: """Show all registered routes with endpoints and methods.""" - rules = list(current_app.url_map.iter_rules()) + if not rules: click.echo("No routes were registered.") return - ignored_methods = set(() if all_methods else ("HEAD", "OPTIONS")) + 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 = [] - if sort in ("endpoint", "rule"): - rules = sorted(rules, key=attrgetter(sort)) - elif sort == "methods": - rules = sorted(rules, key=lambda rule: sorted(rule.methods)) # type: ignore + for rule in rules: + row = [ + rule.endpoint, + ", ".join(sorted((rule.methods or set()) - ignored_methods)), + ] - rule_methods = [ - ", ".join(sorted(rule.methods - ignored_methods)) # type: ignore - for rule in rules - ] + if has_domain: + row.append((rule.host if host_matching else rule.subdomain) or "") - headers = ("Endpoint", "Methods", "Rule") - widths = ( - max(len(rule.endpoint) for rule in rules), - max(len(methods) for methods in rule_methods), - max(len(rule.rule) for rule in rules), - ) - widths = [max(len(h), w) for h, w in zip(headers, widths)] - row = "{{0:<{0}}} {{1:<{1}}} {{2:<{2}}}".format(*widths) + row.append(rule.rule) + rows.append(row) - click.echo(row.format(*headers).strip()) - click.echo(row.format(*("-" * width for width in widths))) + headers = ["Endpoint", "Methods"] + sorts = ["endpoint", "methods"] - for rule, methods in zip(rules, rule_methods): - click.echo(row.format(rule.endpoint, methods, rule.rule).rstrip()) + if has_domain: + headers.append("Host" if host_matching else "Subdomain") + sorts.append("domain") + + headers.append("Rule") + sorts.append("rule") + + try: + rows.sort(key=itemgetter(sorts.index(sort))) + except ValueError: + pass + + rows.insert(0, headers) + widths = [max(len(row[i]) for row in rows) for i in range(len(headers))] + rows.insert(1, ["-" * w for w in widths]) + template = " ".join(f"{{{i}:<{w}}}" for i, w in enumerate(widths)) + + for row in rows: + click.echo(template.format(*row)) cli = FlaskGroup( diff --git a/tests/test_cli.py b/tests/test_cli.py index 0d9625b1..4b6995fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -433,16 +433,12 @@ class TestRoutes: @pytest.fixture def app(self): app = Flask(__name__) - app.testing = True - - @app.route("/get_post//", methods=["GET", "POST"]) - def yyy_get_post(x, y): - pass - - @app.route("/zzz_post", methods=["POST"]) - def aaa_post(): - pass - + app.add_url_rule( + "/get_post//", + methods=["GET", "POST"], + endpoint="yyy_get_post", + ) + app.add_url_rule("/zzz_post", methods=["POST"], endpoint="aaa_post") return app @pytest.fixture @@ -450,17 +446,6 @@ class TestRoutes: cli = FlaskGroup(create_app=lambda: app) return partial(runner.invoke, cli) - @pytest.fixture - def invoke_no_routes(self, runner): - def create_app(): - app = Flask(__name__, static_folder=None) - app.testing = True - - return app - - cli = FlaskGroup(create_app=create_app) - return partial(runner.invoke, cli) - def expect_order(self, order, output): # skip the header and match the start of each row for expect, line in zip(order, output.splitlines()[2:]): @@ -493,11 +478,31 @@ class TestRoutes: output = invoke(["routes", "--all-methods"]).output assert "GET, HEAD, OPTIONS, POST" in output - def test_no_routes(self, invoke_no_routes): - result = invoke_no_routes(["routes"]) + def test_no_routes(self, runner): + app = Flask(__name__, static_folder=None) + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes"]) assert result.exit_code == 0 assert "No routes were registered." in result.output + def test_subdomain(self, runner): + app = Flask(__name__, static_folder=None) + app.add_url_rule("/a", subdomain="a", endpoint="a") + app.add_url_rule("/b", subdomain="b", endpoint="b") + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes"]) + assert result.exit_code == 0 + assert "Subdomain" in result.output + + def test_host(self, runner): + app = Flask(__name__, static_folder=None, host_matching=True) + app.add_url_rule("/a", host="a", endpoint="a") + app.add_url_rule("/b", host="b", endpoint="b") + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes"]) + assert result.exit_code == 0 + assert "Host" in result.output + def dotenv_not_available(): try: