plain 0.60.0__py3-none-any.whl → 0.62.0__py3-none-any.whl
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.
- plain/AGENTS.md +18 -0
- plain/CHANGELOG.md +26 -0
- plain/cli/agent/__init__.py +20 -0
- plain/cli/agent/docs.py +80 -0
- plain/cli/agent/llmdocs.py +145 -0
- plain/cli/agent/md.py +87 -0
- plain/cli/{agent.py → agent/prompt.py} +10 -15
- plain/cli/agent/request.py +181 -0
- plain/cli/core.py +2 -2
- plain/cli/docs.py +21 -201
- plain/cli/install.py +1 -1
- plain/cli/shell.py +15 -1
- plain/cli/upgrade.py +1 -1
- plain/csrf/middleware.py +1 -1
- plain/internal/handlers/base.py +1 -1
- plain/internal/handlers/exception.py +1 -1
- plain/logs/README.md +104 -26
- plain/logs/__init__.py +1 -3
- plain/logs/configure.py +35 -43
- plain/logs/debug.py +36 -0
- plain/logs/formatters.py +70 -0
- plain/logs/loggers.py +182 -73
- plain/runtime/__init__.py +8 -4
- plain/runtime/global_settings.py +6 -2
- plain/templates/AGENTS.md +3 -0
- plain/views/objects.py +4 -3
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/METADATA +2 -2
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/RECORD +31 -23
- plain/cli/help.py +0 -27
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/WHEEL +0 -0
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/entry_points.txt +0 -0
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/licenses/LICENSE +0 -0
plain/AGENTS.md
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# Plain AGENTS.md
|
2
|
+
|
3
|
+
Plain is a Python web framework that was originally forked from Django. While it still has a lot in common with Django, there are also significant changes -- don't solely rely on knowledge of Django when working with Plain.
|
4
|
+
|
5
|
+
## Commands
|
6
|
+
|
7
|
+
The `plain` CLI is the main entrypoint for the framework. If `plain` is not available by itself, try `uv run plain`.
|
8
|
+
|
9
|
+
- `plain shell -c <command>`: Run a Python command with Plain configured.
|
10
|
+
- `plain run <filename>`: Run a Python script with Plain configured.
|
11
|
+
- `plain agent docs <package>`: Show README.md and symbolicated source files for a specific package.
|
12
|
+
- `plain agent docs --list`: List packages with docs available.
|
13
|
+
- `plain agent request <path> --user <user_id>`: Make an authenticated request to the application and inspect the output.
|
14
|
+
- `plain --help`: List all available commands (including those from installed packages).
|
15
|
+
|
16
|
+
## Code style
|
17
|
+
|
18
|
+
- Imports should be at the top of the file, unless there is a specific reason to import later (e.g. to avoid circular imports).
|
plain/CHANGELOG.md
CHANGED
@@ -1,5 +1,31 @@
|
|
1
1
|
# plain changelog
|
2
2
|
|
3
|
+
## [0.62.0](https://github.com/dropseed/plain/releases/plain@0.62.0) (2025-09-09)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- Complete rewrite of logging settings and AppLogger with improved formatters and debug capabilities ([ea7c953](https://github.com/dropseed/plain/commit/ea7c9537e3))
|
8
|
+
- Added `app_logger.debug_mode()` context manager to temporarily change log level ([f535459](https://github.com/dropseed/plain/commit/f53545f9fa))
|
9
|
+
- Minimum Python version updated to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307efb))
|
10
|
+
|
11
|
+
### Upgrade instructions
|
12
|
+
|
13
|
+
- Make sure you are using Python 3.13 or higher
|
14
|
+
|
15
|
+
## [0.61.0](https://github.com/dropseed/plain/releases/plain@0.61.0) (2025-09-03)
|
16
|
+
|
17
|
+
### What's changed
|
18
|
+
|
19
|
+
- Added new `plain agent` command with subcommands for coding agents including `docs`, `md`, and `request` ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
|
20
|
+
- Added `-c` option to `plain shell` to execute commands and exit, similar to `python -c` ([5e67f0b](https://github.com/dropseed/plain/commit/5e67f0bcd8))
|
21
|
+
- The `plain docs --llm` functionality has been moved to `plain agent docs` command ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
|
22
|
+
- Removed the `plain help` command in favor of standard `plain --help` ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
|
23
|
+
|
24
|
+
### Upgrade instructions
|
25
|
+
|
26
|
+
- Replace `plain docs --llm` usage with `plain agent docs` command
|
27
|
+
- Use `plain --help` instead of `plain help` command
|
28
|
+
|
3
29
|
## [0.60.0](https://github.com/dropseed/plain/releases/plain@0.60.0) (2025-08-27)
|
4
30
|
|
5
31
|
### What's changed
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import click
|
2
|
+
|
3
|
+
from .docs import docs
|
4
|
+
from .md import md
|
5
|
+
from .request import request
|
6
|
+
|
7
|
+
|
8
|
+
@click.group("agent", invoke_without_command=True)
|
9
|
+
@click.pass_context
|
10
|
+
def agent(ctx):
|
11
|
+
"""Tools for coding agents."""
|
12
|
+
if ctx.invoked_subcommand is None:
|
13
|
+
# If no subcommand provided, show all AGENTS.md files
|
14
|
+
ctx.invoke(md, show_all=True, show_list=False, package="")
|
15
|
+
|
16
|
+
|
17
|
+
# Add commands to the group
|
18
|
+
agent.add_command(docs)
|
19
|
+
agent.add_command(md)
|
20
|
+
agent.add_command(request)
|
plain/cli/agent/docs.py
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
import importlib.util
|
2
|
+
import pkgutil
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import click
|
6
|
+
|
7
|
+
from .llmdocs import LLMDocs
|
8
|
+
|
9
|
+
|
10
|
+
@click.command()
|
11
|
+
@click.argument("package", default="", required=False)
|
12
|
+
@click.option(
|
13
|
+
"--list",
|
14
|
+
"show_list",
|
15
|
+
is_flag=True,
|
16
|
+
help="List available packages",
|
17
|
+
)
|
18
|
+
def docs(package, show_list):
|
19
|
+
"""Show LLM-friendly documentation and source for a package."""
|
20
|
+
|
21
|
+
if show_list:
|
22
|
+
# List available packages using same discovery logic as md command
|
23
|
+
try:
|
24
|
+
available_packages = []
|
25
|
+
|
26
|
+
# Check for plain.* subpackages (including core plain)
|
27
|
+
try:
|
28
|
+
import plain
|
29
|
+
|
30
|
+
# Check core plain package (namespace package)
|
31
|
+
plain_spec = importlib.util.find_spec("plain")
|
32
|
+
if plain_spec and plain_spec.submodule_search_locations:
|
33
|
+
available_packages.append("plain")
|
34
|
+
|
35
|
+
# Check other plain.* subpackages
|
36
|
+
for importer, modname, ispkg in pkgutil.iter_modules(
|
37
|
+
plain.__path__, "plain."
|
38
|
+
):
|
39
|
+
if ispkg:
|
40
|
+
available_packages.append(modname)
|
41
|
+
except Exception:
|
42
|
+
pass
|
43
|
+
|
44
|
+
if available_packages:
|
45
|
+
for pkg in sorted(available_packages):
|
46
|
+
click.echo(f"- {pkg}")
|
47
|
+
else:
|
48
|
+
click.echo("No packages found.")
|
49
|
+
except Exception as e:
|
50
|
+
click.echo(f"Error listing packages: {e}")
|
51
|
+
return
|
52
|
+
|
53
|
+
if not package:
|
54
|
+
raise click.UsageError(
|
55
|
+
"Package name required. Usage: plain agent docs [package-name]"
|
56
|
+
)
|
57
|
+
|
58
|
+
# Convert hyphens to dots (e.g., plain-models -> plain.models)
|
59
|
+
package = package.replace("-", ".")
|
60
|
+
|
61
|
+
# Automatically prefix if we need to
|
62
|
+
if not package.startswith("plain"):
|
63
|
+
package = f"plain.{package}"
|
64
|
+
|
65
|
+
try:
|
66
|
+
# Get the path for this specific package
|
67
|
+
spec = importlib.util.find_spec(package)
|
68
|
+
if not spec or not spec.origin:
|
69
|
+
raise click.UsageError(f"Package {package} not found")
|
70
|
+
|
71
|
+
package_path = Path(spec.origin).parent
|
72
|
+
paths = [package_path]
|
73
|
+
|
74
|
+
# Generate docs for this specific package
|
75
|
+
source_docs = LLMDocs(paths)
|
76
|
+
source_docs.load()
|
77
|
+
source_docs.print(relative_to=package_path.parent)
|
78
|
+
|
79
|
+
except Exception as e:
|
80
|
+
raise click.UsageError(f"Error loading documentation for {package}: {e}")
|
@@ -0,0 +1,145 @@
|
|
1
|
+
import ast
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
import click
|
5
|
+
|
6
|
+
|
7
|
+
class LLMDocs:
|
8
|
+
"""Generates LLM-friendly documentation."""
|
9
|
+
|
10
|
+
def __init__(self, paths):
|
11
|
+
self.paths = paths
|
12
|
+
|
13
|
+
def load(self):
|
14
|
+
self.docs = set()
|
15
|
+
self.sources = set()
|
16
|
+
|
17
|
+
for path in self.paths:
|
18
|
+
if path.is_dir():
|
19
|
+
self.docs.update(path.glob("**/*.md"))
|
20
|
+
self.sources.update(path.glob("**/*.py"))
|
21
|
+
elif path.suffix == ".py":
|
22
|
+
self.sources.add(path)
|
23
|
+
elif path.suffix == ".md":
|
24
|
+
self.docs.add(path)
|
25
|
+
|
26
|
+
# Exclude "migrations" code from plain apps, except for plain/models/migrations
|
27
|
+
# Also exclude CHANGELOG.md and AGENTS.md
|
28
|
+
self.docs = {
|
29
|
+
doc
|
30
|
+
for doc in self.docs
|
31
|
+
if not (
|
32
|
+
"/migrations/" in str(doc)
|
33
|
+
and "/plain/models/migrations/" not in str(doc)
|
34
|
+
)
|
35
|
+
and doc.name not in ("CHANGELOG.md", "AGENTS.md")
|
36
|
+
}
|
37
|
+
self.sources = {
|
38
|
+
source
|
39
|
+
for source in self.sources
|
40
|
+
if not (
|
41
|
+
"/migrations/" in str(source)
|
42
|
+
and "/plain/models/migrations/" not in str(source)
|
43
|
+
)
|
44
|
+
and source.name != "cli.py"
|
45
|
+
}
|
46
|
+
|
47
|
+
self.docs = sorted(self.docs)
|
48
|
+
self.sources = sorted(self.sources)
|
49
|
+
|
50
|
+
def display_path(self, path):
|
51
|
+
if "plain" in path.parts:
|
52
|
+
root_index = path.parts.index("plain")
|
53
|
+
elif "plainx" in path.parts:
|
54
|
+
root_index = path.parts.index("plainx")
|
55
|
+
else:
|
56
|
+
raise ValueError("Path does not contain 'plain' or 'plainx'")
|
57
|
+
|
58
|
+
plain_root = Path(*path.parts[: root_index + 1])
|
59
|
+
return path.relative_to(plain_root.parent)
|
60
|
+
|
61
|
+
def print(self, relative_to=None):
|
62
|
+
for doc in self.docs:
|
63
|
+
if relative_to:
|
64
|
+
display_path = doc.relative_to(relative_to)
|
65
|
+
else:
|
66
|
+
display_path = self.display_path(doc)
|
67
|
+
click.secho(f"<Docs: {display_path}>", fg="yellow")
|
68
|
+
click.echo(doc.read_text())
|
69
|
+
click.secho(f"</Docs: {display_path}>", fg="yellow")
|
70
|
+
click.echo()
|
71
|
+
|
72
|
+
for source in self.sources:
|
73
|
+
if symbolicated := self.symbolicate(source):
|
74
|
+
if relative_to:
|
75
|
+
display_path = source.relative_to(relative_to)
|
76
|
+
else:
|
77
|
+
display_path = self.display_path(source)
|
78
|
+
click.secho(f"<Source: {display_path}>", fg="yellow")
|
79
|
+
click.echo(symbolicated)
|
80
|
+
click.secho(f"</Source: {display_path}>", fg="yellow")
|
81
|
+
click.echo()
|
82
|
+
|
83
|
+
@staticmethod
|
84
|
+
def symbolicate(file_path: Path):
|
85
|
+
if "internal" in str(file_path).split("/"):
|
86
|
+
return ""
|
87
|
+
|
88
|
+
source = file_path.read_text()
|
89
|
+
|
90
|
+
parsed = ast.parse(source)
|
91
|
+
|
92
|
+
def should_skip(node):
|
93
|
+
if isinstance(node, ast.ClassDef | ast.FunctionDef):
|
94
|
+
if any(
|
95
|
+
isinstance(d, ast.Name) and d.id == "internalcode"
|
96
|
+
for d in node.decorator_list
|
97
|
+
):
|
98
|
+
return True
|
99
|
+
if node.name.startswith("_"):
|
100
|
+
return True
|
101
|
+
elif isinstance(node, ast.Assign):
|
102
|
+
for target in node.targets:
|
103
|
+
if isinstance(target, ast.Name) and target.id.startswith("_"):
|
104
|
+
return True
|
105
|
+
return False
|
106
|
+
|
107
|
+
def process_node(node, indent=0):
|
108
|
+
lines = []
|
109
|
+
prefix = " " * indent
|
110
|
+
|
111
|
+
if should_skip(node):
|
112
|
+
return []
|
113
|
+
|
114
|
+
if isinstance(node, ast.ClassDef):
|
115
|
+
decorators = [
|
116
|
+
f"{prefix}@{ast.unparse(d)}"
|
117
|
+
for d in node.decorator_list
|
118
|
+
if not (isinstance(d, ast.Name) and d.id == "internalcode")
|
119
|
+
]
|
120
|
+
lines.extend(decorators)
|
121
|
+
bases = [ast.unparse(base) for base in node.bases]
|
122
|
+
lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
|
123
|
+
for child in node.body:
|
124
|
+
child_lines = process_node(child, indent + 1)
|
125
|
+
if child_lines:
|
126
|
+
lines.extend(child_lines)
|
127
|
+
|
128
|
+
elif isinstance(node, ast.FunctionDef):
|
129
|
+
decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
|
130
|
+
lines.extend(decorators)
|
131
|
+
args = ast.unparse(node.args)
|
132
|
+
lines.append(f"{prefix}def {node.name}({args})")
|
133
|
+
|
134
|
+
elif isinstance(node, ast.Assign):
|
135
|
+
for target in node.targets:
|
136
|
+
if isinstance(target, ast.Name):
|
137
|
+
lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
|
138
|
+
|
139
|
+
return lines
|
140
|
+
|
141
|
+
symbolicated_lines = []
|
142
|
+
for node in parsed.body:
|
143
|
+
symbolicated_lines.extend(process_node(node))
|
144
|
+
|
145
|
+
return "\n".join(symbolicated_lines)
|
plain/cli/agent/md.py
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
import importlib.util
|
2
|
+
import pkgutil
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import click
|
6
|
+
|
7
|
+
from ..output import iterate_markdown
|
8
|
+
|
9
|
+
|
10
|
+
def _get_packages_with_agents():
|
11
|
+
"""Get dict mapping package names to AGENTS.md paths."""
|
12
|
+
agents_files = {}
|
13
|
+
|
14
|
+
# Check for plain.* subpackages (including core plain)
|
15
|
+
try:
|
16
|
+
import plain
|
17
|
+
|
18
|
+
# Check core plain package (namespace package)
|
19
|
+
plain_spec = importlib.util.find_spec("plain")
|
20
|
+
if plain_spec and plain_spec.submodule_search_locations:
|
21
|
+
# For namespace packages, use the first search location
|
22
|
+
plain_path = Path(plain_spec.submodule_search_locations[0])
|
23
|
+
agents_path = plain_path / "AGENTS.md"
|
24
|
+
if agents_path.exists():
|
25
|
+
agents_files["plain"] = agents_path
|
26
|
+
|
27
|
+
# Check other plain.* subpackages
|
28
|
+
for importer, modname, ispkg in pkgutil.iter_modules(plain.__path__, "plain."):
|
29
|
+
if ispkg:
|
30
|
+
try:
|
31
|
+
spec = importlib.util.find_spec(modname)
|
32
|
+
if spec and spec.origin:
|
33
|
+
package_path = Path(spec.origin).parent
|
34
|
+
# Look for AGENTS.md at package root
|
35
|
+
agents_path = package_path / "AGENTS.md"
|
36
|
+
if agents_path.exists():
|
37
|
+
agents_files[modname] = agents_path
|
38
|
+
except Exception:
|
39
|
+
continue
|
40
|
+
except Exception:
|
41
|
+
pass
|
42
|
+
|
43
|
+
return agents_files
|
44
|
+
|
45
|
+
|
46
|
+
@click.command("md")
|
47
|
+
@click.argument("package", default="", required=False)
|
48
|
+
@click.option(
|
49
|
+
"--all",
|
50
|
+
"show_all",
|
51
|
+
is_flag=True,
|
52
|
+
help="Show AGENTS.md for all packages that have them",
|
53
|
+
)
|
54
|
+
@click.option(
|
55
|
+
"--list",
|
56
|
+
"show_list",
|
57
|
+
is_flag=True,
|
58
|
+
help="List packages with AGENTS.md files",
|
59
|
+
)
|
60
|
+
def md(package, show_all, show_list):
|
61
|
+
"""Show AGENTS.md for a package."""
|
62
|
+
|
63
|
+
agents_files = _get_packages_with_agents()
|
64
|
+
|
65
|
+
if show_list:
|
66
|
+
for pkg in sorted(agents_files.keys()):
|
67
|
+
click.echo(f"- {pkg}")
|
68
|
+
|
69
|
+
return
|
70
|
+
|
71
|
+
if show_all:
|
72
|
+
for pkg in sorted(agents_files.keys()):
|
73
|
+
agents_path = agents_files[pkg]
|
74
|
+
for line in iterate_markdown(agents_path.read_text()):
|
75
|
+
click.echo(line, nl=False)
|
76
|
+
print()
|
77
|
+
|
78
|
+
return
|
79
|
+
|
80
|
+
if not package:
|
81
|
+
raise click.UsageError(
|
82
|
+
"Package name or --all required. Use --list to see available packages."
|
83
|
+
)
|
84
|
+
|
85
|
+
agents_path = agents_files[package]
|
86
|
+
for line in iterate_markdown(agents_path.read_text()):
|
87
|
+
click.echo(line, nl=False)
|
@@ -5,24 +5,19 @@ import subprocess
|
|
5
5
|
import click
|
6
6
|
|
7
7
|
|
8
|
+
def is_agent_environment():
|
9
|
+
"""Check if we're running inside a coding agent."""
|
10
|
+
return bool(
|
11
|
+
os.environ.get("CLAUDECODE")
|
12
|
+
or os.environ.get("CODEX_SANDBOX")
|
13
|
+
or os.environ.get("CURSOR_ENVIRONMENT")
|
14
|
+
)
|
15
|
+
|
16
|
+
|
8
17
|
def prompt_agent(
|
9
18
|
prompt: str, agent_command: str | None = None, print_only: bool = False
|
10
19
|
) -> bool:
|
11
|
-
|
12
|
-
Run an agent command with the given prompt, or display the prompt for manual copying.
|
13
|
-
|
14
|
-
Args:
|
15
|
-
prompt: The prompt to send to the agent
|
16
|
-
agent_command: Optional command to run (e.g., "claude code"). If not provided,
|
17
|
-
will check the PLAIN_AGENT_COMMAND environment variable.
|
18
|
-
print_only: If True, always print the prompt instead of running the agent
|
19
|
-
|
20
|
-
Returns:
|
21
|
-
True if the agent command succeeded (or no agent command was provided),
|
22
|
-
False if the agent command failed.
|
23
|
-
"""
|
24
|
-
# Check if running inside an agent and just print the prompt if so
|
25
|
-
if os.environ.get("CLAUDECODE") or os.environ.get("CODEX_SANDBOX"):
|
20
|
+
if is_agent_environment():
|
26
21
|
click.echo(prompt)
|
27
22
|
return True
|
28
23
|
|
@@ -0,0 +1,181 @@
|
|
1
|
+
import json
|
2
|
+
|
3
|
+
import click
|
4
|
+
|
5
|
+
from plain.runtime import settings
|
6
|
+
from plain.test import Client
|
7
|
+
|
8
|
+
|
9
|
+
@click.command()
|
10
|
+
@click.argument("path")
|
11
|
+
@click.option(
|
12
|
+
"--method",
|
13
|
+
default="GET",
|
14
|
+
help="HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)",
|
15
|
+
)
|
16
|
+
@click.option(
|
17
|
+
"--data",
|
18
|
+
help="Request data (JSON string for POST/PUT/PATCH)",
|
19
|
+
)
|
20
|
+
@click.option(
|
21
|
+
"--user",
|
22
|
+
"user_id",
|
23
|
+
help="User ID to authenticate as (skips normal authentication)",
|
24
|
+
)
|
25
|
+
@click.option(
|
26
|
+
"--follow/--no-follow",
|
27
|
+
default=True,
|
28
|
+
help="Follow redirects (default: True)",
|
29
|
+
)
|
30
|
+
@click.option(
|
31
|
+
"--content-type",
|
32
|
+
help="Content-Type header for request data",
|
33
|
+
)
|
34
|
+
@click.option(
|
35
|
+
"--header",
|
36
|
+
"headers",
|
37
|
+
multiple=True,
|
38
|
+
help="Additional headers (format: 'Name: Value')",
|
39
|
+
)
|
40
|
+
def request(path, method, data, user_id, follow, content_type, headers):
|
41
|
+
"""Make an HTTP request using the test client against the development database."""
|
42
|
+
|
43
|
+
try:
|
44
|
+
# Only allow in DEBUG mode for security
|
45
|
+
if not settings.DEBUG:
|
46
|
+
click.secho("This command only works when DEBUG=True", fg="red", err=True)
|
47
|
+
return
|
48
|
+
|
49
|
+
# Temporarily add testserver to ALLOWED_HOSTS so the test client can make requests
|
50
|
+
original_allowed_hosts = settings.ALLOWED_HOSTS
|
51
|
+
settings.ALLOWED_HOSTS = ["*"]
|
52
|
+
|
53
|
+
try:
|
54
|
+
# Create test client
|
55
|
+
client = Client()
|
56
|
+
|
57
|
+
# If user_id provided, force login
|
58
|
+
if user_id:
|
59
|
+
try:
|
60
|
+
# Get the User model using plain.auth utility
|
61
|
+
from plain.auth import get_user_model
|
62
|
+
|
63
|
+
User = get_user_model()
|
64
|
+
|
65
|
+
# Get the user
|
66
|
+
try:
|
67
|
+
user = User.objects.get(id=user_id)
|
68
|
+
client.force_login(user)
|
69
|
+
click.secho(
|
70
|
+
f"Authenticated as user {user_id}", fg="green", dim=True
|
71
|
+
)
|
72
|
+
except User.DoesNotExist:
|
73
|
+
click.secho(f"User {user_id} not found", fg="red", err=True)
|
74
|
+
return
|
75
|
+
|
76
|
+
except Exception as e:
|
77
|
+
click.secho(f"Authentication error: {e}", fg="red", err=True)
|
78
|
+
return
|
79
|
+
|
80
|
+
# Parse additional headers
|
81
|
+
header_dict = {}
|
82
|
+
for header in headers:
|
83
|
+
if ":" in header:
|
84
|
+
key, value = header.split(":", 1)
|
85
|
+
header_dict[key.strip()] = value.strip()
|
86
|
+
|
87
|
+
# Prepare request data
|
88
|
+
if data and content_type and "json" in content_type.lower():
|
89
|
+
try:
|
90
|
+
# Validate JSON
|
91
|
+
json.loads(data)
|
92
|
+
except json.JSONDecodeError as e:
|
93
|
+
click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
|
94
|
+
return
|
95
|
+
|
96
|
+
# Make the request
|
97
|
+
method = method.upper()
|
98
|
+
kwargs = {
|
99
|
+
"path": path,
|
100
|
+
"follow": follow,
|
101
|
+
"headers": header_dict or None,
|
102
|
+
}
|
103
|
+
|
104
|
+
if method in ("POST", "PUT", "PATCH") and data:
|
105
|
+
kwargs["data"] = data
|
106
|
+
if content_type:
|
107
|
+
kwargs["content_type"] = content_type
|
108
|
+
|
109
|
+
# Call the appropriate client method
|
110
|
+
if method == "GET":
|
111
|
+
response = client.get(**kwargs)
|
112
|
+
elif method == "POST":
|
113
|
+
response = client.post(**kwargs)
|
114
|
+
elif method == "PUT":
|
115
|
+
response = client.put(**kwargs)
|
116
|
+
elif method == "PATCH":
|
117
|
+
response = client.patch(**kwargs)
|
118
|
+
elif method == "DELETE":
|
119
|
+
response = client.delete(**kwargs)
|
120
|
+
elif method == "HEAD":
|
121
|
+
response = client.head(**kwargs)
|
122
|
+
elif method == "OPTIONS":
|
123
|
+
response = client.options(**kwargs)
|
124
|
+
elif method == "TRACE":
|
125
|
+
response = client.trace(**kwargs)
|
126
|
+
else:
|
127
|
+
click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
|
128
|
+
return
|
129
|
+
|
130
|
+
# Display response information
|
131
|
+
click.secho(
|
132
|
+
f"HTTP {response.status_code}",
|
133
|
+
fg="green" if response.status_code < 400 else "red",
|
134
|
+
bold=True,
|
135
|
+
)
|
136
|
+
|
137
|
+
# Show additional response info first
|
138
|
+
if hasattr(response, "user"):
|
139
|
+
click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
|
140
|
+
|
141
|
+
if hasattr(response, "resolver_match") and response.resolver_match:
|
142
|
+
match = response.resolver_match
|
143
|
+
url_name = match.namespaced_url_name or match.url_name or "unnamed"
|
144
|
+
click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
|
145
|
+
|
146
|
+
# Show headers
|
147
|
+
if response.headers:
|
148
|
+
click.secho("Response Headers:", fg="yellow", bold=True)
|
149
|
+
for key, value in response.headers.items():
|
150
|
+
click.echo(f" {key}: {value}")
|
151
|
+
click.echo()
|
152
|
+
|
153
|
+
# Show response content last
|
154
|
+
if response.content:
|
155
|
+
content_type = response.headers.get("Content-Type", "")
|
156
|
+
|
157
|
+
if "json" in content_type.lower():
|
158
|
+
try:
|
159
|
+
json_data = response.json()
|
160
|
+
click.secho("Response Body (JSON):", fg="yellow", bold=True)
|
161
|
+
click.echo(json.dumps(json_data, indent=2))
|
162
|
+
except Exception:
|
163
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
164
|
+
click.echo(response.content.decode("utf-8", errors="replace"))
|
165
|
+
elif "html" in content_type.lower():
|
166
|
+
click.secho("Response Body (HTML):", fg="yellow", bold=True)
|
167
|
+
content = response.content.decode("utf-8", errors="replace")
|
168
|
+
click.echo(content)
|
169
|
+
else:
|
170
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
171
|
+
content = response.content.decode("utf-8", errors="replace")
|
172
|
+
click.echo(content)
|
173
|
+
else:
|
174
|
+
click.secho("(No response body)", fg="yellow", dim=True)
|
175
|
+
|
176
|
+
finally:
|
177
|
+
# Restore original ALLOWED_HOSTS
|
178
|
+
settings.ALLOWED_HOSTS = original_allowed_hosts
|
179
|
+
|
180
|
+
except Exception as e:
|
181
|
+
click.secho(f"Request failed: {e}", fg="red", err=True)
|
plain/cli/core.py
CHANGED
@@ -6,12 +6,12 @@ from click.core import Command, Context
|
|
6
6
|
import plain.runtime
|
7
7
|
from plain.exceptions import ImproperlyConfigured
|
8
8
|
|
9
|
+
from .agent import agent
|
9
10
|
from .build import build
|
10
11
|
from .changelog import changelog
|
11
12
|
from .chores import chores
|
12
13
|
from .docs import docs
|
13
14
|
from .formatting import PlainContext
|
14
|
-
from .help import help_cmd
|
15
15
|
from .install import install
|
16
16
|
from .preflight import preflight_checks
|
17
17
|
from .registry import cli_registry
|
@@ -28,6 +28,7 @@ def plain_cli():
|
|
28
28
|
pass
|
29
29
|
|
30
30
|
|
31
|
+
plain_cli.add_command(agent)
|
31
32
|
plain_cli.add_command(docs)
|
32
33
|
plain_cli.add_command(preflight_checks)
|
33
34
|
plain_cli.add_command(create)
|
@@ -41,7 +42,6 @@ plain_cli.add_command(shell)
|
|
41
42
|
plain_cli.add_command(run)
|
42
43
|
plain_cli.add_command(install)
|
43
44
|
plain_cli.add_command(upgrade)
|
44
|
-
plain_cli.add_command(help_cmd)
|
45
45
|
|
46
46
|
|
47
47
|
class CLIRegistryGroup(click.Group):
|