plain 0.60.0__py3-none-any.whl → 0.61.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,14 @@
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)
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.61.0](https://github.com/dropseed/plain/releases/plain@0.61.0) (2025-09-03)
4
+
5
+ ### What's changed
6
+
7
+ - Added new `plain agent` command with subcommands for coding agents including `docs`, `md`, and `request` ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
8
+ - Added `-c` option to `plain shell` to execute commands and exit, similar to `python -c` ([5e67f0b](https://github.com/dropseed/plain/commit/5e67f0bcd8))
9
+ - The `plain docs --llm` functionality has been moved to `plain agent docs` command ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
10
+ - Removed the `plain help` command in favor of standard `plain --help` ([df3edbf](https://github.com/dropseed/plain/commit/df3edbf0bd))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Replace `plain docs --llm` usage with `plain agent docs` command
15
+ - Use `plain --help` instead of `plain help` command
16
+
3
17
  ## [0.60.0](https://github.com/dropseed/plain/releases/plain@0.60.0) (2025-08-27)
4
18
 
5
19
  ### 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):
plain/cli/docs.py CHANGED
@@ -1,218 +1,38 @@
1
- import ast
2
1
  import importlib.util
3
2
  from pathlib import Path
4
3
 
5
4
  import click
6
5
 
7
- from plain.packages import packages_registry
8
-
9
6
  from .output import iterate_markdown
10
7
 
11
8
 
12
9
  @click.command()
13
- @click.option("--llm", "llm", is_flag=True)
14
10
  @click.option("--open")
15
11
  @click.argument("module", default="")
16
- def docs(module, llm, open):
17
- if not module and not llm:
18
- raise click.UsageError("You must specify a module or use --llm")
19
-
20
- if llm:
21
- paths = [Path(__file__).parent.parent]
22
-
23
- for package_config in packages_registry.get_package_configs():
24
- if package_config.name.startswith("app."):
25
- # Ignore app packages for now
26
- continue
27
-
28
- paths.append(Path(package_config.path))
29
-
30
- source_docs = LLMDocs(paths)
31
- source_docs.load()
32
- source_docs.print()
33
-
34
- click.secho(
35
- "That's everything! Copy this into your AI tool of choice.",
36
- err=True,
37
- fg="green",
12
+ def docs(module, open):
13
+ if not module:
14
+ raise click.UsageError(
15
+ "You must specify a module. For LLM-friendly docs, use `plain agent docs`."
38
16
  )
39
17
 
40
- return
41
-
42
- if module:
43
- # Convert hyphens to dots (e.g., plain-models -> plain.models)
44
- module = module.replace("-", ".")
45
-
46
- # Automatically prefix if we need to
47
- if not module.startswith("plain"):
48
- module = f"plain.{module}"
49
-
50
- # Get the README.md file for the module
51
- spec = importlib.util.find_spec(module)
52
- if not spec:
53
- raise click.UsageError(f"Module {module} not found")
54
-
55
- module_path = Path(spec.origin).parent
56
- readme_path = module_path / "README.md"
57
- if not readme_path.exists():
58
- raise click.UsageError(f"README.md not found for {module}")
59
-
60
- if open:
61
- click.launch(str(readme_path))
62
- else:
63
- click.echo_via_pager(iterate_markdown(readme_path.read_text()))
64
-
65
-
66
- class LLMDocs:
67
- preamble = (
68
- "Below is all of the documentation and abbreviated source code for the Plain web framework. "
69
- "Your job is to read and understand it, and then act as the Plain Framework Assistant and "
70
- "help the developer accomplish whatever they want to do next."
71
- "\n\n---\n\n"
72
- )
73
-
74
- def __init__(self, paths):
75
- self.paths = paths
76
-
77
- def load(self):
78
- self.docs = set()
79
- self.sources = set()
80
-
81
- for path in self.paths:
82
- if path.is_dir():
83
- self.docs.update(path.glob("**/*.md"))
84
- self.sources.update(path.glob("**/*.py"))
85
- elif path.suffix == ".py":
86
- self.sources.add(path)
87
- elif path.suffix == ".md":
88
- self.docs.add(path)
89
-
90
- # Exclude "migrations" code from plain apps, except for plain/models/migrations
91
- self.docs = {
92
- doc
93
- for doc in self.docs
94
- if not (
95
- "/migrations/" in str(doc)
96
- and "/plain/models/migrations/" not in str(doc)
97
- )
98
- }
99
- self.sources = {
100
- source
101
- for source in self.sources
102
- if not (
103
- "/migrations/" in str(source)
104
- and "/plain/models/migrations/" not in str(source)
105
- )
106
- }
107
-
108
- self.docs = sorted(self.docs)
109
- self.sources = sorted(self.sources)
110
-
111
- def display_path(self, path):
112
- if "plain" in path.parts:
113
- root_index = path.parts.index("plain")
114
- elif "plainx" in path.parts:
115
- root_index = path.parts.index("plainx")
116
- else:
117
- raise ValueError("Path does not contain 'plain' or 'plainx'")
118
-
119
- plain_root = Path(*path.parts[: root_index + 1])
120
- return path.relative_to(plain_root.parent)
121
-
122
- def print(self, relative_to=None):
123
- click.secho(self.preamble, fg="yellow")
124
-
125
- for doc in self.docs:
126
- if relative_to:
127
- display_path = doc.relative_to(relative_to)
128
- else:
129
- display_path = self.display_path(doc)
130
- click.secho(f"<Docs: {display_path}>", fg="yellow")
131
- click.echo(doc.read_text())
132
- click.secho(f"</Docs: {display_path}>", fg="yellow")
133
- click.echo()
134
-
135
- for source in self.sources:
136
- if symbolicated := self.symbolicate(source):
137
- if relative_to:
138
- display_path = source.relative_to(relative_to)
139
- else:
140
- display_path = self.display_path(source)
141
- click.secho(f"<Source: {display_path}>", fg="yellow")
142
- click.echo(symbolicated)
143
- click.secho(f"</Source: {display_path}>", fg="yellow")
144
- click.echo()
145
-
146
- @staticmethod
147
- def symbolicate(file_path: Path):
148
- if "internal" in str(file_path).split("/"):
149
- return ""
150
-
151
- source = file_path.read_text()
152
-
153
- parsed = ast.parse(source)
154
-
155
- def should_skip(node):
156
- if isinstance(node, ast.ClassDef | ast.FunctionDef):
157
- if any(
158
- isinstance(d, ast.Name) and d.id == "internalcode"
159
- for d in node.decorator_list
160
- ):
161
- return True
162
- if node.name.startswith("_"): # and not node.name.endswith("__"):
163
- return True
164
- elif isinstance(node, ast.Assign):
165
- for target in node.targets:
166
- if (
167
- isinstance(target, ast.Name) and target.id.startswith("_")
168
- # and not target.id.endswith("__")
169
- ):
170
- return True
171
- return False
172
-
173
- def process_node(node, indent=0):
174
- lines = []
175
- prefix = " " * indent
176
-
177
- if should_skip(node):
178
- return []
179
-
180
- if isinstance(node, ast.ClassDef):
181
- decorators = [
182
- f"{prefix}@{ast.unparse(d)}"
183
- for d in node.decorator_list
184
- if not (isinstance(d, ast.Name) and d.id == "internalcode")
185
- ]
186
- lines.extend(decorators)
187
- bases = [ast.unparse(base) for base in node.bases]
188
- lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
189
- # if ast.get_docstring(node):
190
- # lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
191
- for child in node.body:
192
- child_lines = process_node(child, indent + 1)
193
- if child_lines:
194
- lines.extend(child_lines)
195
- # if not has_body:
196
- # lines.append(f"{prefix} pass")
197
-
198
- elif isinstance(node, ast.FunctionDef):
199
- decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
200
- lines.extend(decorators)
201
- args = ast.unparse(node.args)
202
- lines.append(f"{prefix}def {node.name}({args})")
203
- # if ast.get_docstring(node):
204
- # lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
205
- # lines.append(f"{prefix} pass")
18
+ # Convert hyphens to dots (e.g., plain-models -> plain.models)
19
+ module = module.replace("-", ".")
206
20
 
207
- elif isinstance(node, ast.Assign):
208
- for target in node.targets:
209
- if isinstance(target, ast.Name):
210
- lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
21
+ # Automatically prefix if we need to
22
+ if not module.startswith("plain"):
23
+ module = f"plain.{module}"
211
24
 
212
- return lines
25
+ # Get the README.md file for the module
26
+ spec = importlib.util.find_spec(module)
27
+ if not spec:
28
+ raise click.UsageError(f"Module {module} not found")
213
29
 
214
- symbolicated_lines = []
215
- for node in parsed.body:
216
- symbolicated_lines.extend(process_node(node))
30
+ module_path = Path(spec.origin).parent
31
+ readme_path = module_path / "README.md"
32
+ if not readme_path.exists():
33
+ raise click.UsageError(f"README.md not found for {module}")
217
34
 
218
- return "\n".join(symbolicated_lines)
35
+ if open:
36
+ click.launch(str(readme_path))
37
+ else:
38
+ click.echo_via_pager(iterate_markdown(readme_path.read_text()))
plain/cli/install.py CHANGED
@@ -3,7 +3,7 @@ import sys
3
3
 
4
4
  import click
5
5
 
6
- from .agent import prompt_agent
6
+ from .agent.prompt import prompt_agent
7
7
 
8
8
 
9
9
  @click.command()
plain/cli/shell.py CHANGED
@@ -12,12 +12,26 @@ import click
12
12
  type=click.Choice(["ipython", "bpython", "python"]),
13
13
  help="Specify an interactive interpreter interface.",
14
14
  )
15
- def shell(interface):
15
+ @click.option(
16
+ "-c",
17
+ "--command",
18
+ help="Execute the given command and exit.",
19
+ )
20
+ def shell(interface, command):
16
21
  """
17
22
  Runs a Python interactive interpreter. Tries to use IPython or
18
23
  bpython, if one of them is available.
19
24
  """
20
25
 
26
+ if command:
27
+ # Execute the command and exit
28
+ before_script = "import plain.runtime; plain.runtime.setup()"
29
+ full_command = f"{before_script}; {command}"
30
+ result = subprocess.run(["python", "-c", full_command])
31
+ if result.returncode:
32
+ sys.exit(result.returncode)
33
+ return
34
+
21
35
  if interface:
22
36
  interface = [interface]
23
37
  else:
plain/cli/upgrade.py CHANGED
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  import click
7
7
 
8
- from .agent import prompt_agent
8
+ from .agent.prompt import prompt_agent
9
9
 
10
10
  LOCK_FILE = Path("uv.lock")
11
11
 
@@ -0,0 +1,3 @@
1
+ # Plain Templates AGENTS.md
2
+
3
+ - Plain templates use Jinja2.
plain/views/objects.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from functools import cached_property
2
+ from typing import Any
2
3
 
3
4
  from plain.exceptions import ImproperlyConfigured, ObjectDoesNotExist
4
5
  from plain.forms import Form
@@ -38,19 +39,19 @@ class ObjectTemplateViewMixin:
38
39
  context_object_name = ""
39
40
 
40
41
  @cached_property
41
- def object(self):
42
+ def object(self) -> Any:
42
43
  try:
43
44
  obj = self.get_object()
44
45
  except ObjectDoesNotExist:
45
46
  raise Http404
46
47
 
48
+ # Also raise 404 if get_object() returns None
47
49
  if not obj:
48
- # Also raise 404 if the object is None
49
50
  raise Http404
50
51
 
51
52
  return obj
52
53
 
53
- def get_object(self): # Intentionally untyped... subclasses must override this.
54
+ def get_object(self) -> Any:
54
55
  raise NotImplementedError(
55
56
  f"get_object() is not implemented on {self.__class__.__name__}"
56
57
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.60.0
3
+ Version: 0.61.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,4 +1,5 @@
1
- plain/CHANGELOG.md,sha256=D_pNXRYALt2M76lgFw0DsNBweBA3wjnESTQycSEMz68,10968
1
+ plain/AGENTS.md,sha256=Euyn6UG9gumJj1vXi6PFPmhRAlwedUfK7UcYTBF9Y9Y,893
2
+ plain/CHANGELOG.md,sha256=X0ep2U-LXpociHtkEk-PTB30VVFiZHOocGunQgrqGAQ,11831
2
3
  plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
3
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
4
5
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
@@ -20,26 +21,30 @@ plain/chores/__init__.py,sha256=r9TXtQCH-VbvfnIJ5F8FxgQC35GRWFOfmMZN3q9niLg,67
20
21
  plain/chores/registry.py,sha256=V3WjuekRI22LFvJbqSkUXQtiOtuE2ZK8gKV1TRvxRUI,1866
21
22
  plain/cli/README.md,sha256=5C7vsH0ISxu7q5H6buC25MBOILkI_rzdySitswpQgJw,1032
22
23
  plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
23
- plain/cli/agent.py,sha256=nf-Tuc3abxpyV-nShBn1wq0JWjfgd3zY9lLH6rAZSRs,1678
24
24
  plain/cli/build.py,sha256=Lo6AYghJz0DM9fIVUSiBSOKa5vR0XCOxZWEjza6sc8Q,3172
25
25
  plain/cli/changelog.py,sha256=j-k1yZk9mpm-fLZgeWastiyIisxNSuAJfXTQ2B6WQmk,3457
26
26
  plain/cli/chores.py,sha256=xXSSFvr8T5jWfLWqe6E8YVMw1BkQxyOHHVuY0x9RH0A,2412
27
- plain/cli/core.py,sha256=HZZcDVNEMAhantDM4Hnug-SrwU3YgAq1-KDa3_a9-K0,3111
28
- plain/cli/docs.py,sha256=MjFIpNc0FsHACi_spPVumXM-oPCi__3U0Jqf41lqjQM,7481
27
+ plain/cli/core.py,sha256=g0D1OZkYGWt05-V1oDNzX2wcoCIAxrZjlgaQs2qWLlc,3106
28
+ plain/cli/docs.py,sha256=YEEE-Th1CSxiL-wj5fF-ZagqkqAZYkEPRMO1OYUsQrU,1066
29
29
  plain/cli/formatting.py,sha256=1hZH13y1qwHcU2K2_Na388nw9uvoeQH8LrWL-O9h8Yc,2207
30
- plain/cli/help.py,sha256=NefZSEIixrX_WELVSnJDHRpLDWf7_4PXmkkMm3Q2mzo,787
31
- plain/cli/install.py,sha256=wvEc3gLP5tvSb1Xwm2467DCXqCPTWxuzktGnlK94SGg,2784
30
+ plain/cli/install.py,sha256=mffSYBmSJSj44OPBfu53nBQoyoz4jk69DvppubIB0mU,2791
32
31
  plain/cli/output.py,sha256=Fe3xS6Va4Bi1ZNrqi0nh09THTsdCyMW2b9SPY5I4n-o,1318
33
32
  plain/cli/preflight.py,sha256=8tHBD4L4nPLUKThfaYx3SUZSJzC48oV2m_Hbn6W4ODc,4124
34
33
  plain/cli/print.py,sha256=XraUYrgODOJquIiEv78wSCYGRBplHXtXSS9QtFG5hqY,217
35
34
  plain/cli/registry.py,sha256=yKVMSDjW8g10nlV9sPXFGJQmhC_U-k4J4kM7N2OQVLA,1467
36
35
  plain/cli/scaffold.py,sha256=mcywA9DzfwoBSqWl5-Zpgcy1mTNUGEgdvoxXUrGcEVk,1351
37
36
  plain/cli/settings.py,sha256=9cx4bue664I2P7kUedlf4YhCPB0tSKSE4Q8mGyzEv2o,1995
38
- plain/cli/shell.py,sha256=iIwvlTdTBjLBBUdXMAmIRWSoynszOZI79-mrBg4RegU,1373
37
+ plain/cli/shell.py,sha256=PMHdwcRv48qXDToeq82aZaNth-cKc3V2pQ1yISrNMvY,1802
39
38
  plain/cli/startup.py,sha256=wLaFuyUb4ewWhtehBCGicrRCXIIGCRbeCT3ce9hUv-A,1022
40
- plain/cli/upgrade.py,sha256=eGVWm0gpn-Pr6uPsfzojRmh_VU5--B0h9dYfQuXSzi8,5625
39
+ plain/cli/upgrade.py,sha256=T8u81rA2_dSfJaK4vF1_OPkQpspBVWnlPxatyk_mdx0,5632
41
40
  plain/cli/urls.py,sha256=ghCW36aRszxmTo06A50FIvYopb6kQ07QekkDzM6_A1o,3824
42
41
  plain/cli/utils.py,sha256=VwlIh0z7XxzVV8I3qM2kZo07fkJFPoeeVZa1ODG616k,258
42
+ plain/cli/agent/__init__.py,sha256=Ipp65kuIF14TVxNqsj71MsWUePaKHUcdP3QmaYyNcg0,480
43
+ plain/cli/agent/docs.py,sha256=ubX3ZeRHxVaetLk9fjiN9mJ07GZExC-CHUvQoX2DD7c,2464
44
+ plain/cli/agent/llmdocs.py,sha256=AUpNDb1xSOsSpzGOiFvpzUe4f7PUGMiR9cI13aVZouo,5038
45
+ plain/cli/agent/md.py,sha256=7r1II8ckubBFOZNGPASWaPmJdgByWFPINLqIOzRetLQ,2581
46
+ plain/cli/agent/prompt.py,sha256=rugYyQHV7JDNqGrx3_PPShwwqYlnEVbxw8RsczOo8tg,1253
47
+ plain/cli/agent/request.py,sha256=JILrcxEMPagBXWrjNGMy3qatCYCXw-_uJMKkVHk_bho,6549
43
48
  plain/csrf/README.md,sha256=ApWpB-qlEf0LkOKm9Yr-6f_lB9XJEvGFDo_fraw8ghI,2391
44
49
  plain/csrf/middleware.py,sha256=d_vb8l0-KxzyqCivVq0jTCsFOm-ljwjmjVuZXKVYR5U,5113
45
50
  plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
@@ -98,6 +103,7 @@ plain/signals/__init__.py,sha256=eAs0kLqptuP6I31dWXeAqRNji3svplpAV4Ez6ktjwXM,131
98
103
  plain/signals/dispatch/__init__.py,sha256=FzEygqV9HsM6gopio7O2Oh_X230nA4d5Q9s0sUjMq0E,292
99
104
  plain/signals/dispatch/dispatcher.py,sha256=VxSlqn9PCOTghPPJLOqZPs6FNQZfV2BJpMfFMSg6Dtc,11531
100
105
  plain/signals/dispatch/license.txt,sha256=o9EhDhsC4Q5HbmD-IfNGVTEkXtNE33r5rIt3lleJ8gc,1727
106
+ plain/templates/AGENTS.md,sha256=o6H_qEZ2eY5IrNmSZG7o7ZYfqdD0SkJm_2VPBaaOo5w,59
101
107
  plain/templates/README.md,sha256=QAQxoygpc0CE13fh4eH4ZILwl2xc-oMdGKtiZLLrNCk,2565
102
108
  plain/templates/__init__.py,sha256=bX76FakE9T7mfK3N0deN85HlwHNQpeigytSC9Z8LcOs,451
103
109
  plain/templates/core.py,sha256=mbcH0yTeFOI3XOg9dYSroXRIcdv9sETEy4HzY-ugwco,1258
@@ -149,11 +155,11 @@ plain/views/base.py,sha256=CC9UvMZeAjVvi90vGjoZzsQ0jnhbg3-7qCKQ8-Pb6cg,4184
149
155
  plain/views/errors.py,sha256=jbNCJIzowwCsEvqyJ3opMeZpPDqTyhtrbqb0VnAm2HE,1263
150
156
  plain/views/exceptions.py,sha256=b4euI49ZUKS9O8AGAcFfiDpstzkRAuuj_uYQXzWNHME,138
151
157
  plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
152
- plain/views/objects.py,sha256=YNb8MO1I99HTmQghC5nFk25TQmaB_s45K5yg5BGt4qY,5018
158
+ plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
153
159
  plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
154
160
  plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
155
- plain-0.60.0.dist-info/METADATA,sha256=w-VNXX3R5YsTPJ_UBnFSzu33jUHnwPHAQdzMJZxILhc,4488
156
- plain-0.60.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
157
- plain-0.60.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
158
- plain-0.60.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
159
- plain-0.60.0.dist-info/RECORD,,
161
+ plain-0.61.0.dist-info/METADATA,sha256=9fm4uV_Uo8yyhaMeWayg-UyS4_y1_W6sI_tKNONklAY,4488
162
+ plain-0.61.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
163
+ plain-0.61.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
164
+ plain-0.61.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
165
+ plain-0.61.0.dist-info/RECORD,,
plain/cli/help.py DELETED
@@ -1,27 +0,0 @@
1
- import click
2
- from click.core import Group
3
-
4
-
5
- @click.command("help")
6
- @click.pass_context
7
- def help_cmd(ctx):
8
- """Show help for all commands and subcommands."""
9
-
10
- root = ctx.parent.command
11
- info_name = ctx.parent.info_name or "plain"
12
-
13
- def print_help(cmd, prog, parent=None):
14
- sub_ctx = click.Context(cmd, info_name=prog, parent=parent)
15
-
16
- title = sub_ctx.command_path
17
- click.secho(title, fg="green", bold=True)
18
- click.secho("-" * len(title), fg="green")
19
- click.echo(sub_ctx.get_help())
20
-
21
- if isinstance(cmd, Group):
22
- for name in cmd.list_commands(sub_ctx):
23
- click.echo()
24
- sub_cmd = cmd.get_command(sub_ctx, name)
25
- print_help(sub_cmd, name, sub_ctx)
26
-
27
- print_help(root, info_name)
File without changes