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 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)
@@ -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):