plain 0.35.0__py3-none-any.whl → 0.37.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/cli/core.py CHANGED
@@ -1,26 +1,21 @@
1
- import ast
2
- import importlib.util
3
- import os
4
- import shutil
5
- import subprocess
6
- import sys
7
- import tomllib
8
1
  import traceback
9
- from importlib.metadata import entry_points
10
- from pathlib import Path
11
2
 
12
3
  import click
13
4
  from click.core import Command, Context
14
5
 
15
6
  import plain.runtime
16
- from plain import preflight
17
- from plain.assets.compile import compile_assets, get_compiled_path
18
7
  from plain.exceptions import ImproperlyConfigured
19
- from plain.packages import packages_registry
20
- from plain.utils.crypto import get_random_string
21
8
 
9
+ from .build import build
10
+ from .docs import docs
22
11
  from .formatting import PlainContext
12
+ from .preflight import preflight_checks
23
13
  from .registry import cli_registry
14
+ from .scaffold import create
15
+ from .settings import setting
16
+ from .shell import run, shell
17
+ from .urls import urls
18
+ from .utils import utils
24
19
 
25
20
 
26
21
  @click.group()
@@ -28,678 +23,15 @@ def plain_cli():
28
23
  pass
29
24
 
30
25
 
31
- def symbolicate(file_path: Path):
32
- if "internal" in str(file_path).split("/"):
33
- return ""
34
-
35
- source = file_path.read_text()
36
-
37
- parsed = ast.parse(source)
38
-
39
- def should_skip(node):
40
- if isinstance(node, ast.ClassDef | ast.FunctionDef):
41
- if any(
42
- isinstance(d, ast.Name) and d.id == "internalcode"
43
- for d in node.decorator_list
44
- ):
45
- return True
46
- if node.name.startswith("_"): # and not node.name.endswith("__"):
47
- return True
48
- elif isinstance(node, ast.Assign):
49
- for target in node.targets:
50
- if (
51
- isinstance(target, ast.Name) and target.id.startswith("_")
52
- # and not target.id.endswith("__")
53
- ):
54
- return True
55
- return False
56
-
57
- def process_node(node, indent=0):
58
- lines = []
59
- prefix = " " * indent
60
-
61
- if should_skip(node):
62
- return []
63
-
64
- if isinstance(node, ast.ClassDef):
65
- decorators = [
66
- f"{prefix}@{ast.unparse(d)}"
67
- for d in node.decorator_list
68
- if not (isinstance(d, ast.Name) and d.id == "internal")
69
- ]
70
- lines.extend(decorators)
71
- bases = [ast.unparse(base) for base in node.bases]
72
- lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
73
- # if ast.get_docstring(node):
74
- # lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
75
- for child in node.body:
76
- child_lines = process_node(child, indent + 1)
77
- if child_lines:
78
- lines.extend(child_lines)
79
- # if not has_body:
80
- # lines.append(f"{prefix} pass")
81
-
82
- elif isinstance(node, ast.FunctionDef):
83
- decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
84
- lines.extend(decorators)
85
- args = ast.unparse(node.args)
86
- lines.append(f"{prefix}def {node.name}({args})")
87
- # if ast.get_docstring(node):
88
- # lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
89
- # lines.append(f"{prefix} pass")
90
-
91
- elif isinstance(node, ast.Assign):
92
- for target in node.targets:
93
- if isinstance(target, ast.Name):
94
- lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
95
-
96
- return lines
97
-
98
- symbolicated_lines = []
99
- for node in parsed.body:
100
- symbolicated_lines.extend(process_node(node))
101
-
102
- return "\n".join(symbolicated_lines)
103
-
104
-
105
- @plain_cli.command
106
- @click.option("--llm", "llm", is_flag=True)
107
- @click.option("--open")
108
- @click.argument("module", default="")
109
- def docs(module, llm, open):
110
- if not module and not llm:
111
- click.secho("You must specify a module or use --llm", fg="red")
112
- sys.exit(1)
113
-
114
- if llm:
115
- click.echo(
116
- "Below is all of the documentation and abbreviated source code for the Plain web framework. "
117
- "Your job is to read and understand it, and then act as the Plain Framework Assistant and "
118
- "help the developer accomplish whatever they want to do next."
119
- "\n\n---\n\n"
120
- )
121
-
122
- docs = set()
123
- sources = set()
124
-
125
- # Get everything for Plain core
126
- for path in Path(__file__).parent.parent.glob("**/*.md"):
127
- docs.add(path)
128
- for source in Path(__file__).parent.parent.glob("**/*.py"):
129
- sources.add(source)
130
-
131
- # Find every *.md file in the other plain packages and installed apps
132
- for package_config in packages_registry.get_package_configs():
133
- if package_config.name.startswith("app."):
134
- # Ignore app packages for now
135
- continue
136
-
137
- for path in Path(package_config.path).glob("**/*.md"):
138
- docs.add(path)
139
-
140
- for source in Path(package_config.path).glob("**/*.py"):
141
- sources.add(source)
142
-
143
- docs = sorted(docs)
144
- sources = sorted(sources)
145
-
146
- for doc in docs:
147
- try:
148
- display_path = doc.relative_to(Path.cwd())
149
- except ValueError:
150
- display_path = doc.absolute()
151
- click.secho(f"<Docs: {display_path}>", fg="yellow")
152
- click.echo(doc.read_text())
153
- click.secho(f"</Docs: {display_path}>", fg="yellow")
154
- click.echo()
155
-
156
- for source in sources:
157
- if symbolicated := symbolicate(source):
158
- try:
159
- display_path = source.relative_to(Path.cwd())
160
- except ValueError:
161
- display_path = source.absolute()
162
- click.secho(f"<Source: {display_path}>", fg="yellow")
163
- click.echo(symbolicated)
164
- click.secho(f"</Source: {display_path}>", fg="yellow")
165
- click.echo()
166
-
167
- click.secho(
168
- "That's everything! Copy this into your AI tool of choice.",
169
- err=True,
170
- fg="green",
171
- )
172
-
173
- return
174
-
175
- if module:
176
- # Automatically prefix if we need to
177
- if not module.startswith("plain"):
178
- module = f"plain.{module}"
179
-
180
- # Get the README.md file for the module
181
- spec = importlib.util.find_spec(module)
182
- if not spec:
183
- click.secho(f"Module {module} not found", fg="red")
184
- sys.exit(1)
185
-
186
- module_path = Path(spec.origin).parent
187
- readme_path = module_path / "README.md"
188
- if not readme_path.exists():
189
- click.secho(f"README.md not found for {module}", fg="red")
190
- sys.exit(1)
191
-
192
- if open:
193
- click.launch(str(readme_path))
194
- else:
195
-
196
- def _iterate_markdown(content):
197
- """
198
- Iterator that does basic markdown for a Click pager.
199
-
200
- Headings are yellow and bright, code blocks are indented.
201
- """
202
-
203
- in_code_block = False
204
- for line in content.splitlines():
205
- if line.startswith("```"):
206
- in_code_block = not in_code_block
207
-
208
- if in_code_block:
209
- yield click.style(line, dim=True)
210
- elif line.startswith("# "):
211
- yield click.style(line, fg="yellow", bold=True)
212
- elif line.startswith("## "):
213
- yield click.style(line, fg="yellow", bold=True)
214
- elif line.startswith("### "):
215
- yield click.style(line, fg="yellow", bold=True)
216
- elif line.startswith("#### "):
217
- yield click.style(line, fg="yellow", bold=True)
218
- elif line.startswith("##### "):
219
- yield click.style(line, fg="yellow", bold=True)
220
- elif line.startswith("###### "):
221
- yield click.style(line, fg="yellow", bold=True)
222
- elif line.startswith("**") and line.endswith("**"):
223
- yield click.style(line, bold=True)
224
- elif line.startswith("> "):
225
- yield click.style(line, italic=True)
226
- else:
227
- yield line
228
-
229
- yield "\n"
230
-
231
- click.echo_via_pager(_iterate_markdown(readme_path.read_text()))
232
-
233
-
234
- @plain_cli.command()
235
- @click.option(
236
- "-i",
237
- "--interface",
238
- type=click.Choice(["ipython", "bpython", "python"]),
239
- help="Specify an interactive interpreter interface.",
240
- )
241
- def shell(interface):
242
- """
243
- Runs a Python interactive interpreter. Tries to use IPython or
244
- bpython, if one of them is available.
245
- """
246
-
247
- if interface:
248
- interface = [interface]
249
- else:
250
-
251
- def get_default_interface():
252
- try:
253
- import IPython # noqa
254
-
255
- return ["python", "-m", "IPython"]
256
- except ImportError:
257
- pass
258
-
259
- return ["python"]
260
-
261
- interface = get_default_interface()
262
-
263
- result = subprocess.run(
264
- interface,
265
- env={
266
- "PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"),
267
- **os.environ,
268
- },
269
- )
270
- if result.returncode:
271
- sys.exit(result.returncode)
272
-
273
-
274
- @plain_cli.command()
275
- @click.argument("script", nargs=1, type=click.Path(exists=True))
276
- def run(script):
277
- """Run a Python script in the context of your app"""
278
- before_script = "import plain.runtime; plain.runtime.setup()"
279
- command = f"{before_script}; exec(open('{script}').read())"
280
- result = subprocess.run(["python", "-c", command])
281
- if result.returncode:
282
- sys.exit(result.returncode)
283
-
284
-
285
- # @plain_cli.command()
286
- # @click.option("--filter", "-f", "name_filter", help="Filter settings by name")
287
- # @click.option("--overridden", is_flag=True, help="Only show overridden settings")
288
- # def settings(name_filter, overridden):
289
- # """Print Plain settings"""
290
- # table = Table(box=box.MINIMAL)
291
- # table.add_column("Setting")
292
- # table.add_column("Default value")
293
- # table.add_column("App value")
294
- # table.add_column("Type")
295
- # table.add_column("Module")
296
-
297
- # for setting in dir(settings):
298
- # if setting.isupper():
299
- # if name_filter and name_filter.upper() not in setting:
300
- # continue
301
-
302
- # is_overridden = settings.is_overridden(setting)
303
-
304
- # if overridden and not is_overridden:
305
- # continue
306
-
307
- # default_setting = settings._default_settings.get(setting)
308
- # if default_setting:
309
- # default_value = default_setting.value
310
- # annotation = default_setting.annotation
311
- # module = default_setting.module
312
- # else:
313
- # default_value = ""
314
- # annotation = ""
315
- # module = ""
316
-
317
- # table.add_row(
318
- # setting,
319
- # Pretty(default_value) if default_value else "",
320
- # Pretty(getattr(settings, setting))
321
- # if is_overridden
322
- # else Text("<Default>", style="italic dim"),
323
- # Pretty(annotation) if annotation else "",
324
- # str(module.__name__) if module else "",
325
- # )
326
-
327
- # console = Console()
328
- # console.print(table)
329
-
330
-
331
- @plain_cli.command("preflight")
332
- @click.argument("package_label", nargs=-1)
333
- @click.option(
334
- "--deploy",
335
- is_flag=True,
336
- help="Check deployment settings.",
337
- )
338
- @click.option(
339
- "--fail-level",
340
- default="ERROR",
341
- type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]),
342
- help="Message level that will cause the command to exit with a non-zero status. Default is ERROR.",
343
- )
344
- @click.option(
345
- "--database",
346
- "databases",
347
- multiple=True,
348
- help="Run database related checks against these aliases.",
349
- )
350
- def preflight_checks(package_label, deploy, fail_level, databases):
351
- """
352
- Use the system check framework to validate entire Plain project.
353
- Raise CommandError for any serious message (error or critical errors).
354
- If there are only light messages (like warnings), print them to stderr
355
- and don't raise an exception.
356
- """
357
- include_deployment_checks = deploy
358
-
359
- if package_label:
360
- package_configs = [
361
- packages_registry.get_package_config(label) for label in package_label
362
- ]
363
- else:
364
- package_configs = None
365
-
366
- all_issues = preflight.run_checks(
367
- package_configs=package_configs,
368
- include_deployment_checks=include_deployment_checks,
369
- databases=databases,
370
- )
371
-
372
- header, body, footer = "", "", ""
373
- visible_issue_count = 0 # excludes silenced warnings
374
-
375
- if all_issues:
376
- debugs = [
377
- e for e in all_issues if e.level < preflight.INFO and not e.is_silenced()
378
- ]
379
- infos = [
380
- e
381
- for e in all_issues
382
- if preflight.INFO <= e.level < preflight.WARNING and not e.is_silenced()
383
- ]
384
- warnings = [
385
- e
386
- for e in all_issues
387
- if preflight.WARNING <= e.level < preflight.ERROR and not e.is_silenced()
388
- ]
389
- errors = [
390
- e
391
- for e in all_issues
392
- if preflight.ERROR <= e.level < preflight.CRITICAL and not e.is_silenced()
393
- ]
394
- criticals = [
395
- e
396
- for e in all_issues
397
- if preflight.CRITICAL <= e.level and not e.is_silenced()
398
- ]
399
- sorted_issues = [
400
- (criticals, "CRITICALS"),
401
- (errors, "ERRORS"),
402
- (warnings, "WARNINGS"),
403
- (infos, "INFOS"),
404
- (debugs, "DEBUGS"),
405
- ]
406
-
407
- for issues, group_name in sorted_issues:
408
- if issues:
409
- visible_issue_count += len(issues)
410
- formatted = (
411
- click.style(str(e), fg="red")
412
- if e.is_serious()
413
- else click.style(str(e), fg="yellow")
414
- for e in issues
415
- )
416
- formatted = "\n".join(sorted(formatted))
417
- body += f"\n{group_name}:\n{formatted}\n"
418
-
419
- if visible_issue_count:
420
- header = "Preflight check identified some issues:\n"
421
-
422
- if any(
423
- e.is_serious(getattr(preflight, fail_level)) and not e.is_silenced()
424
- for e in all_issues
425
- ):
426
- footer += "\n"
427
- footer += "Preflight check identified {} ({} silenced).".format(
428
- "no issues"
429
- if visible_issue_count == 0
430
- else "1 issue"
431
- if visible_issue_count == 1
432
- else f"{visible_issue_count} issues",
433
- len(all_issues) - visible_issue_count,
434
- )
435
- msg = click.style(f"SystemCheckError: {header}", fg="red") + body + footer
436
- raise click.ClickException(msg)
437
- else:
438
- if visible_issue_count:
439
- footer += "\n"
440
- footer += "Preflight check identified {} ({} silenced).".format(
441
- "no issues"
442
- if visible_issue_count == 0
443
- else "1 issue"
444
- if visible_issue_count == 1
445
- else f"{visible_issue_count} issues",
446
- len(all_issues) - visible_issue_count,
447
- )
448
- msg = header + body + footer
449
- click.echo(msg, err=True)
450
- else:
451
- click.secho("✔ Preflight check identified no issues.", err=True, fg="green")
452
-
453
-
454
- @plain_cli.command()
455
- @click.option(
456
- "--keep-original/--no-keep-original",
457
- "keep_original",
458
- is_flag=True,
459
- default=False,
460
- help="Keep the original assets",
461
- )
462
- @click.option(
463
- "--fingerprint/--no-fingerprint",
464
- "fingerprint",
465
- is_flag=True,
466
- default=True,
467
- help="Fingerprint the assets",
468
- )
469
- @click.option(
470
- "--compress/--no-compress",
471
- "compress",
472
- is_flag=True,
473
- default=True,
474
- help="Compress the assets",
475
- )
476
- def build(keep_original, fingerprint, compress):
477
- """Pre-deployment build step (compile assets, css, js, etc.)"""
478
-
479
- if not keep_original and not fingerprint:
480
- click.secho(
481
- "You must either keep the original assets or fingerprint them.",
482
- fg="red",
483
- err=True,
484
- )
485
- sys.exit(1)
486
-
487
- # Run user-defined build commands first
488
- pyproject_path = plain.runtime.APP_PATH.parent / "pyproject.toml"
489
- if pyproject_path.exists():
490
- with pyproject_path.open("rb") as f:
491
- pyproject = tomllib.load(f)
492
-
493
- for name, data in (
494
- pyproject.get("tool", {})
495
- .get("plain", {})
496
- .get("build", {})
497
- .get("run", {})
498
- .items()
499
- ):
500
- click.secho(f"Running {name} from pyproject.toml", bold=True)
501
- result = subprocess.run(data["cmd"], shell=True)
502
- print()
503
- if result.returncode:
504
- click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
505
- sys.exit(result.returncode)
506
-
507
- # Then run installed package build steps (like tailwind, typically should run last...)
508
- for entry_point in entry_points(group="plain.build"):
509
- click.secho(f"Running {entry_point.name}", bold=True)
510
- result = entry_point.load()()
511
- print()
512
-
513
- # Compile our assets
514
- target_dir = get_compiled_path()
515
- click.secho(f"Compiling assets to {target_dir}", bold=True)
516
- if target_dir.exists():
517
- click.secho("(clearing previously compiled assets)")
518
- shutil.rmtree(target_dir)
519
- target_dir.mkdir(parents=True, exist_ok=True)
520
-
521
- total_files = 0
522
- total_compiled = 0
523
-
524
- for url_path, resolved_url_path, compiled_paths in compile_assets(
525
- target_dir=target_dir,
526
- keep_original=keep_original,
527
- fingerprint=fingerprint,
528
- compress=compress,
529
- ):
530
- if url_path == resolved_url_path:
531
- click.secho(url_path, bold=True)
532
- else:
533
- click.secho(url_path, bold=True, nl=False)
534
- click.secho(" → ", fg="yellow", nl=False)
535
- click.echo(resolved_url_path)
536
-
537
- print("\n".join(f" {Path(p).relative_to(Path.cwd())}" for p in compiled_paths))
538
-
539
- total_files += 1
540
- total_compiled += len(compiled_paths)
541
-
542
- click.secho(
543
- f"\nCompiled {total_files} assets into {total_compiled} files", fg="green"
544
- )
545
-
546
- # TODO could do a jinja pre-compile here too?
547
- # environment.compile_templates() but it needs a target, ignore_errors=False
548
-
549
-
550
- @plain_cli.command()
551
- @click.argument("package_name")
552
- def create(package_name):
553
- """
554
- Create a new local package.
555
-
556
- The PACKAGE_NAME is typically a plural noun, like "users" or "posts",
557
- where you might create a "User" or "Post" model inside of the package.
558
- """
559
- package_dir = plain.runtime.APP_PATH / package_name
560
- package_dir.mkdir(exist_ok=True)
561
-
562
- empty_dirs = (
563
- f"templates/{package_name}",
564
- "migrations",
565
- )
566
- for d in empty_dirs:
567
- (package_dir / d).mkdir(parents=True, exist_ok=True)
568
-
569
- empty_files = (
570
- "__init__.py",
571
- "migrations/__init__.py",
572
- "models.py",
573
- "views.py",
574
- )
575
- for f in empty_files:
576
- (package_dir / f).touch(exist_ok=True)
577
-
578
- # Create a urls.py file with a default namespace
579
- if not (package_dir / "urls.py").exists():
580
- (package_dir / "urls.py").write_text(
581
- f"""from plain.urls import path, Router
582
-
583
-
584
- class {package_name.capitalize()}Router(Router):
585
- namespace = f"{package_name}"
586
- urls = [
587
- # path("", views.IndexView, name="index"),
588
- ]
589
- """
590
- )
591
-
592
- click.secho(
593
- f'Created {package_dir.relative_to(Path.cwd())}. Make sure to add "{package_name}" to INSTALLED_PACKAGES!',
594
- fg="green",
595
- )
596
-
597
-
598
- @plain_cli.command()
599
- @click.argument("setting_name")
600
- def setting(setting_name):
601
- """Print the value of a setting at runtime"""
602
- try:
603
- setting = getattr(plain.runtime.settings, setting_name)
604
- click.echo(setting)
605
- except AttributeError:
606
- click.secho(f'Setting "{setting_name}" not found', fg="red")
607
-
608
-
609
- @plain_cli.group()
610
- def utils():
611
- pass
612
-
613
-
614
- @utils.command()
615
- def generate_secret_key():
616
- """Generate a new secret key"""
617
- new_secret_key = get_random_string(50)
618
- click.echo(new_secret_key)
619
-
620
-
621
- @plain_cli.command()
622
- @click.option("--flat", is_flag=True, help="List all URLs in a flat list")
623
- def urls(flat):
624
- """Print all URL patterns under settings.URLS_ROUTER"""
625
- from plain.runtime import settings
626
- from plain.urls import URLResolver, get_resolver
627
-
628
- if not settings.URLS_ROUTER:
629
- click.secho("URLS_ROUTER is not set", fg="red")
630
- sys.exit(1)
631
-
632
- resolver = get_resolver(settings.URLS_ROUTER)
633
- if flat:
634
-
635
- def flat_list(patterns, prefix="", curr_ns=""):
636
- for pattern in patterns:
637
- full_pattern = f"{prefix}{pattern.pattern}"
638
- if isinstance(pattern, URLResolver):
639
- # Update current namespace
640
- new_ns = (
641
- f"{curr_ns}:{pattern.namespace}"
642
- if curr_ns and pattern.namespace
643
- else (pattern.namespace or curr_ns)
644
- )
645
- yield from flat_list(
646
- pattern.url_patterns, prefix=full_pattern, curr_ns=new_ns
647
- )
648
- else:
649
- if pattern.name:
650
- if curr_ns:
651
- styled_namespace = click.style(f"{curr_ns}:", fg="yellow")
652
- styled_name = click.style(pattern.name, fg="blue")
653
- full_name = f"{styled_namespace}{styled_name}"
654
- else:
655
- full_name = click.style(pattern.name, fg="blue")
656
- name_part = f" [{full_name}]"
657
- else:
658
- name_part = ""
659
- yield f"{click.style(full_pattern)}{name_part}"
660
-
661
- for p in flat_list(resolver.url_patterns):
662
- click.echo(p)
663
- else:
664
-
665
- def print_tree(patterns, prefix="", curr_ns=""):
666
- count = len(patterns)
667
- for idx, pattern in enumerate(patterns):
668
- is_last = idx == (count - 1)
669
- connector = "└── " if is_last else "├── "
670
- styled_connector = click.style(connector)
671
- styled_pattern = click.style(pattern.pattern)
672
- if isinstance(pattern, URLResolver):
673
- if pattern.namespace:
674
- new_ns = (
675
- f"{curr_ns}:{pattern.namespace}"
676
- if curr_ns
677
- else pattern.namespace
678
- )
679
- styled_namespace = click.style(f"[{new_ns}]", fg="yellow")
680
- click.echo(
681
- f"{prefix}{styled_connector}{styled_pattern} {styled_namespace}"
682
- )
683
- else:
684
- new_ns = curr_ns
685
- click.echo(f"{prefix}{styled_connector}{styled_pattern}")
686
- extension = " " if is_last else "│ "
687
- print_tree(pattern.url_patterns, prefix + extension, new_ns)
688
- else:
689
- if pattern.name:
690
- if curr_ns:
691
- styled_namespace = click.style(f"{curr_ns}:", fg="yellow")
692
- styled_name = click.style(pattern.name, fg="blue")
693
- full_name = f"[{styled_namespace}{styled_name}]"
694
- else:
695
- full_name = click.style(f"[{pattern.name}]", fg="blue")
696
- click.echo(
697
- f"{prefix}{styled_connector}{styled_pattern} {full_name}"
698
- )
699
- else:
700
- click.echo(f"{prefix}{styled_connector}{styled_pattern}")
701
-
702
- print_tree(resolver.url_patterns)
26
+ plain_cli.add_command(docs)
27
+ plain_cli.add_command(preflight_checks)
28
+ plain_cli.add_command(create)
29
+ plain_cli.add_command(build)
30
+ plain_cli.add_command(utils)
31
+ plain_cli.add_command(urls)
32
+ plain_cli.add_command(setting)
33
+ plain_cli.add_command(shell)
34
+ plain_cli.add_command(run)
703
35
 
704
36
 
705
37
  class CLIRegistryGroup(click.Group):