plain 0.69.0__py3-none-any.whl → 0.70.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.
Files changed (126) hide show
  1. plain/AGENTS.md +1 -1
  2. plain/CHANGELOG.md +11 -0
  3. plain/assets/compile.py +20 -7
  4. plain/assets/finders.py +15 -11
  5. plain/assets/fingerprints.py +6 -5
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +23 -17
  8. plain/chores/registry.py +14 -9
  9. plain/cli/agent/__init__.py +1 -1
  10. plain/cli/agent/docs.py +7 -6
  11. plain/cli/agent/llmdocs.py +18 -8
  12. plain/cli/agent/md.py +19 -14
  13. plain/cli/agent/prompt.py +1 -1
  14. plain/cli/agent/request.py +37 -17
  15. plain/cli/build.py +2 -2
  16. plain/cli/changelog.py +8 -4
  17. plain/cli/chores.py +4 -4
  18. plain/cli/core.py +8 -5
  19. plain/cli/docs.py +2 -2
  20. plain/cli/formatting.py +10 -7
  21. plain/cli/output.py +6 -2
  22. plain/cli/preflight.py +3 -3
  23. plain/cli/print.py +1 -1
  24. plain/cli/registry.py +10 -6
  25. plain/cli/scaffold.py +1 -1
  26. plain/cli/settings.py +1 -1
  27. plain/cli/shell.py +10 -7
  28. plain/cli/startup.py +3 -3
  29. plain/cli/urls.py +10 -4
  30. plain/cli/utils.py +2 -2
  31. plain/csrf/middleware.py +15 -5
  32. plain/csrf/views.py +11 -8
  33. plain/debug.py +5 -2
  34. plain/exceptions.py +19 -8
  35. plain/forms/__init__.py +1 -1
  36. plain/forms/boundfield.py +14 -7
  37. plain/forms/exceptions.py +1 -1
  38. plain/forms/fields.py +139 -97
  39. plain/forms/forms.py +55 -39
  40. plain/http/cookie.py +15 -7
  41. plain/http/multipartparser.py +50 -30
  42. plain/http/request.py +97 -73
  43. plain/http/response.py +99 -80
  44. plain/internal/__init__.py +8 -1
  45. plain/internal/files/base.py +34 -18
  46. plain/internal/files/locks.py +19 -11
  47. plain/internal/files/move.py +8 -3
  48. plain/internal/files/temp.py +23 -5
  49. plain/internal/files/uploadedfile.py +42 -26
  50. plain/internal/files/uploadhandler.py +48 -27
  51. plain/internal/files/utils.py +13 -6
  52. plain/internal/handlers/base.py +20 -6
  53. plain/internal/handlers/exception.py +19 -5
  54. plain/internal/handlers/wsgi.py +30 -18
  55. plain/internal/middleware/headers.py +11 -2
  56. plain/internal/middleware/hosts.py +10 -2
  57. plain/internal/middleware/https.py +13 -3
  58. plain/internal/middleware/slash.py +15 -5
  59. plain/json.py +2 -1
  60. plain/logs/configure.py +3 -1
  61. plain/logs/debug.py +16 -5
  62. plain/logs/formatters.py +6 -3
  63. plain/logs/loggers.py +56 -52
  64. plain/logs/utils.py +19 -9
  65. plain/packages/config.py +14 -6
  66. plain/packages/registry.py +27 -12
  67. plain/paginator.py +31 -21
  68. plain/preflight/checks.py +3 -1
  69. plain/preflight/files.py +3 -1
  70. plain/preflight/registry.py +25 -10
  71. plain/preflight/results.py +10 -4
  72. plain/preflight/security.py +7 -5
  73. plain/preflight/urls.py +4 -1
  74. plain/runtime/__init__.py +4 -3
  75. plain/runtime/global_settings.py +1 -1
  76. plain/runtime/user_settings.py +26 -17
  77. plain/runtime/utils.py +1 -1
  78. plain/signals/dispatch/dispatcher.py +39 -17
  79. plain/signing.py +49 -30
  80. plain/templates/jinja/__init__.py +13 -5
  81. plain/templates/jinja/environments.py +4 -3
  82. plain/templates/jinja/extensions.py +9 -3
  83. plain/templates/jinja/filters.py +7 -2
  84. plain/templates/jinja/globals.py +1 -1
  85. plain/test/client.py +246 -174
  86. plain/test/encoding.py +9 -6
  87. plain/test/exceptions.py +10 -2
  88. plain/urls/converters.py +13 -10
  89. plain/urls/patterns.py +32 -20
  90. plain/urls/resolvers.py +32 -22
  91. plain/urls/utils.py +5 -1
  92. plain/utils/cache.py +14 -8
  93. plain/utils/crypto.py +21 -5
  94. plain/utils/datastructures.py +84 -54
  95. plain/utils/dateparse.py +10 -7
  96. plain/utils/deconstruct.py +12 -4
  97. plain/utils/decorators.py +5 -1
  98. plain/utils/duration.py +8 -4
  99. plain/utils/encoding.py +14 -7
  100. plain/utils/functional.py +62 -47
  101. plain/utils/hashable.py +5 -1
  102. plain/utils/html.py +21 -14
  103. plain/utils/http.py +16 -9
  104. plain/utils/inspect.py +14 -6
  105. plain/utils/ipv6.py +7 -3
  106. plain/utils/itercompat.py +6 -1
  107. plain/utils/module_loading.py +7 -3
  108. plain/utils/regex_helper.py +23 -13
  109. plain/utils/safestring.py +14 -6
  110. plain/utils/text.py +34 -18
  111. plain/utils/timezone.py +30 -19
  112. plain/utils/tree.py +31 -18
  113. plain/validators.py +71 -44
  114. plain/views/base.py +16 -6
  115. plain/views/errors.py +11 -4
  116. plain/views/exceptions.py +4 -1
  117. plain/views/objects.py +15 -15
  118. plain/views/redirect.py +14 -10
  119. plain/views/templates.py +1 -1
  120. plain/wsgi.py +3 -1
  121. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
  122. plain-0.70.0.dist-info/RECORD +169 -0
  123. plain-0.69.0.dist-info/RECORD +0 -169
  124. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
  125. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
  126. {plain-0.69.0.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
plain/AGENTS.md CHANGED
@@ -10,7 +10,7 @@ The `plain` CLI is the main entrypoint for the framework. If `plain` is not avai
10
10
  - `plain run <filename>`: Run a Python script with Plain configured.
11
11
  - `plain agent docs <package>`: Show README.md and symbolicated source files for a specific package.
12
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.
13
+ - `plain agent request <path> --user <user_id>`: Make an authenticated request to the running application and inspect the output.
14
14
  - `plain --help`: List all available commands (including those from installed packages).
15
15
 
16
16
  ## Code style
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.70.0](https://github.com/dropseed/plain/releases/plain@0.70.0) (2025-09-30)
4
+
5
+ ### What's changed
6
+
7
+ - Added comprehensive type annotations throughout the codebase for improved IDE support and type checking ([365414c](https://github.com/dropseed/plain/commit/365414cc6f))
8
+ - The `Asset` class in `plain.assets.finders` is now a module-level public class instead of being defined inside `iter_assets()` ([6321765](https://github.com/dropseed/plain/commit/6321765d30))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
3
14
  ## [0.69.0](https://github.com/dropseed/plain/releases/plain@0.69.0) (2025-09-29)
4
15
 
5
16
  ### What's changed
plain/assets/compile.py CHANGED
@@ -1,10 +1,14 @@
1
+ from __future__ import annotations
2
+
1
3
  import gzip
2
4
  import os
3
5
  import shutil
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
4
8
 
5
9
  from plain.runtime import PLAIN_TEMP_PATH
6
10
 
7
- from .finders import iter_assets
11
+ from .finders import Asset, iter_assets
8
12
  from .fingerprints import AssetsFingerprintsManifest, get_file_fingerprint
9
13
 
10
14
  SKIP_COMPRESS_EXTENSIONS = (
@@ -40,7 +44,7 @@ SKIP_COMPRESS_EXTENSIONS = (
40
44
  )
41
45
 
42
46
 
43
- def get_compiled_path():
47
+ def get_compiled_path() -> Path:
44
48
  """
45
49
  Get the path at runtime to the compiled assets directory.
46
50
 
@@ -49,7 +53,9 @@ def get_compiled_path():
49
53
  return PLAIN_TEMP_PATH / "assets" / "compiled"
50
54
 
51
55
 
52
- def compile_assets(*, target_dir, keep_original, fingerprint, compress):
56
+ def compile_assets(
57
+ *, target_dir: str, keep_original: bool, fingerprint: bool, compress: bool
58
+ ) -> Iterator[tuple[str, str, list[str]]]:
53
59
  """
54
60
  Compile all assets to the target directory and save a JSON manifest
55
61
  mapping the original filenames to the compiled filenames.
@@ -73,17 +79,24 @@ def compile_assets(*, target_dir, keep_original, fingerprint, compress):
73
79
  manifest.save()
74
80
 
75
81
 
76
- def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
82
+ def compile_asset(
83
+ *,
84
+ asset: Asset,
85
+ target_dir: str,
86
+ keep_original: bool,
87
+ fingerprint: bool,
88
+ compress: bool,
89
+ ) -> tuple[str, list[str]]:
77
90
  """
78
91
  Compile an asset to multiple output paths.
79
92
  """
80
- compiled_paths = []
93
+ compiled_paths: list[str] = []
81
94
 
82
95
  # The expected destination for the original asset
83
96
  target_path = os.path.join(target_dir, asset.url_path)
84
97
 
85
98
  # Keep track of where the final, resolved asset ends up
86
- resolved_url_path = asset.url_path
99
+ resolved_url_path: str = asset.url_path
87
100
 
88
101
  # Make sure all the expected directories exist
89
102
  os.makedirs(os.path.dirname(target_path), exist_ok=True)
@@ -106,7 +119,7 @@ def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
106
119
  shutil.copy(asset.absolute_path, fingerprinted_path)
107
120
  compiled_paths.append(fingerprinted_path)
108
121
 
109
- resolved_url_path = os.path.relpath(fingerprinted_path, target_dir)
122
+ resolved_url_path = str(os.path.relpath(fingerprinted_path, target_dir))
110
123
 
111
124
  if compress and extension.lower() not in SKIP_COMPRESS_EXTENSIONS:
112
125
  for path in compiled_paths.copy():
plain/assets/finders.py CHANGED
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
4
+ from collections.abc import Iterator
2
5
 
3
6
  from plain.packages import packages_registry
4
7
  from plain.runtime import APP_PATH
@@ -8,21 +11,22 @@ APP_ASSETS_DIR = APP_PATH / "assets"
8
11
  SKIP_ASSETS = (".DS_Store", ".gitignore")
9
12
 
10
13
 
11
- def iter_assets():
14
+ class Asset:
15
+ def __init__(self, *, url_path: str, absolute_path: str):
16
+ self.url_path = url_path
17
+ self.absolute_path = absolute_path
18
+
19
+ def __str__(self) -> str:
20
+ return self.url_path
21
+
22
+
23
+ def iter_assets() -> Iterator[Asset]:
12
24
  """
13
25
  Iterate all valid asset files found in the installed
14
26
  packages and the app itself.
15
27
  """
16
28
 
17
- class Asset:
18
- def __init__(self, *, url_path, absolute_path):
19
- self.url_path = url_path
20
- self.absolute_path = absolute_path
21
-
22
- def __str__(self):
23
- return self.url_path
24
-
25
- def _iter_assets_dir(path):
29
+ def _iter_assets_dir(path: str) -> Iterator[tuple[str, str]]:
26
30
  for root, _, files in os.walk(path):
27
31
  for f in files:
28
32
  if f in SKIP_ASSETS:
@@ -36,7 +40,7 @@ def iter_assets():
36
40
  yield Asset(url_path=url_path, absolute_path=abs_path)
37
41
 
38
42
 
39
- def iter_asset_dirs():
43
+ def iter_asset_dirs() -> Iterator[str]:
40
44
  """
41
45
  Iterate all directories containing assets, from installed
42
46
  packages and from app/assets.
@@ -15,18 +15,18 @@ class AssetsFingerprintsManifest(dict):
15
15
  def __init__(self):
16
16
  self.path = PLAIN_TEMP_PATH / "assets" / "fingerprints.json"
17
17
 
18
- def load(self):
18
+ def load(self) -> None:
19
19
  if self.path.exists():
20
20
  with open(self.path) as f:
21
21
  self.update(json.load(f))
22
22
 
23
- def save(self):
23
+ def save(self) -> None:
24
24
  with open(self.path, "w") as f:
25
25
  json.dump(self, f, indent=2)
26
26
 
27
27
 
28
28
  @cache
29
- def _get_manifest():
29
+ def _get_manifest() -> AssetsFingerprintsManifest:
30
30
  """
31
31
  A cached function for loading the asset fingerprints manifest,
32
32
  so we don't have to keep loading it from disk over and over.
@@ -36,16 +36,17 @@ def _get_manifest():
36
36
  return manifest
37
37
 
38
38
 
39
- def get_fingerprinted_url_path(url_path):
39
+ def get_fingerprinted_url_path(url_path: str) -> str | None:
40
40
  """
41
41
  Get the final fingerprinted path for an asset URL path.
42
42
  """
43
43
  manifest = _get_manifest()
44
44
  if url_path in manifest:
45
45
  return manifest[url_path]
46
+ return None
46
47
 
47
48
 
48
- def get_file_fingerprint(file_path):
49
+ def get_file_fingerprint(file_path: str) -> str:
49
50
  """
50
51
  Get the fingerprint hash for a file.
51
52
  """
plain/assets/urls.py CHANGED
@@ -18,7 +18,7 @@ class AssetsRouter(Router):
18
18
  ]
19
19
 
20
20
 
21
- def get_asset_url(url_path):
21
+ def get_asset_url(url_path: str) -> str:
22
22
  """
23
23
  Get the full URL to a given asset path.
24
24
  """
plain/assets/views.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import functools
2
4
  import mimetypes
3
5
  import os
@@ -28,14 +30,14 @@ class AssetView(View):
28
30
  This class could be subclassed to further tweak the responses or behavior.
29
31
  """
30
32
 
31
- def __init__(self, asset_path=None):
33
+ def __init__(self, asset_path: str | None = None):
32
34
  # Allow a path to be passed in AssetView.as_view(path="...")
33
35
  self.asset_path = asset_path
34
36
 
35
- def get_url_path(self):
37
+ def get_url_path(self) -> str:
36
38
  return self.asset_path or self.url_kwargs["path"]
37
39
 
38
- def get(self):
40
+ def get(self) -> Response | FileResponse:
39
41
  url_path = self.get_url_path()
40
42
 
41
43
  # Make a trailing slash work, but we don't expect it
@@ -71,7 +73,7 @@ class AssetView(View):
71
73
  response.headers = self.update_headers(response.headers, absolute_path)
72
74
  return response
73
75
 
74
- def get_asset_path(self, path):
76
+ def get_asset_path(self, path: str) -> str:
75
77
  """Get the path to the compiled asset"""
76
78
  compiled_path = os.path.abspath(get_compiled_path())
77
79
  asset_path = os.path.join(compiled_path, path)
@@ -82,13 +84,14 @@ class AssetView(View):
82
84
 
83
85
  return asset_path
84
86
 
85
- def get_debug_asset_path(self, path):
87
+ def get_debug_asset_path(self, path: str) -> str | None:
86
88
  """Make a "live" check to find the uncompiled asset in the filesystem"""
87
89
  for asset in iter_assets():
88
90
  if asset.url_path == path:
89
91
  return asset.absolute_path
92
+ return None
90
93
 
91
- def check_asset_path(self, path):
94
+ def check_asset_path(self, path: str | None) -> None:
92
95
  if not path:
93
96
  raise Http404("Asset not found")
94
97
 
@@ -99,7 +102,7 @@ class AssetView(View):
99
102
  raise Http404("Asset is a directory")
100
103
 
101
104
  @functools.cache
102
- def get_last_modified(self, path):
105
+ def get_last_modified(self, path: str) -> str | None:
103
106
  try:
104
107
  mtime = os.path.getmtime(path)
105
108
  except OSError:
@@ -107,9 +110,10 @@ class AssetView(View):
107
110
 
108
111
  if mtime:
109
112
  return formatdate(mtime, usegmt=True)
113
+ return None
110
114
 
111
115
  @functools.cache
112
- def get_etag(self, path):
116
+ def get_etag(self, path: str) -> str:
113
117
  try:
114
118
  mtime = os.path.getmtime(path)
115
119
  except OSError:
@@ -120,10 +124,10 @@ class AssetView(View):
120
124
  return f'"{timestamp:x}-{size:x}"'
121
125
 
122
126
  @functools.cache
123
- def get_size(self, path):
127
+ def get_size(self, path: str) -> int:
124
128
  return os.path.getsize(path)
125
129
 
126
- def update_headers(self, headers, path):
130
+ def update_headers(self, headers: dict, path: str) -> dict:
127
131
  headers.setdefault("Access-Control-Allow-Origin", "*")
128
132
 
129
133
  # Always vary on Accept-Encoding
@@ -165,7 +169,7 @@ class AssetView(View):
165
169
 
166
170
  return headers
167
171
 
168
- def is_immutable(self, path):
172
+ def is_immutable(self, path: str) -> bool:
169
173
  """
170
174
  Determine whether an asset looks like it is immutable.
171
175
 
@@ -182,14 +186,14 @@ class AssetView(View):
182
186
 
183
187
  return False
184
188
 
185
- def get_encoded_path(self, path):
189
+ def get_encoded_path(self, path: str) -> str | None:
186
190
  """
187
191
  If the client supports compression, return the path to the compressed file.
188
192
  Otherwise, return the original path.
189
193
  """
190
194
  accept_encoding = self.request.headers.get("Accept-Encoding")
191
195
  if not accept_encoding:
192
- return
196
+ return None
193
197
 
194
198
  if "br" in accept_encoding:
195
199
  br_path = path + ".br"
@@ -200,15 +204,16 @@ class AssetView(View):
200
204
  gzip_path = path + ".gz"
201
205
  if os.path.exists(gzip_path):
202
206
  return gzip_path
207
+ return None
203
208
 
204
- def get_redirect_response(self, path):
209
+ def get_redirect_response(self, path: str) -> ResponseRedirect | None:
205
210
  """If the asset is not found, try to redirect to the fingerprinted path"""
206
211
  fingerprinted_url_path = get_fingerprinted_url_path(path)
207
212
 
208
213
  if not fingerprinted_url_path or fingerprinted_url_path == path:
209
214
  # Don't need to redirect if there is no fingerprinted path,
210
215
  # or we're already looking at it.
211
- return
216
+ return None
212
217
 
213
218
  from .urls import AssetsRouter
214
219
 
@@ -221,7 +226,7 @@ class AssetView(View):
221
226
  },
222
227
  )
223
228
 
224
- def get_conditional_response(self, path):
229
+ def get_conditional_response(self, path: str) -> ResponseNotModified | None:
225
230
  """
226
231
  Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers.
227
232
  """
@@ -241,8 +246,9 @@ class AssetView(View):
241
246
  response = ResponseNotModified()
242
247
  response.headers = self.update_headers(response.headers, path)
243
248
  return response
249
+ return None
244
250
 
245
- def get_range_response(self, path):
251
+ def get_range_response(self, path: str) -> Response | StreamingResponse | None:
246
252
  """
247
253
  Support range requests (HTTP 206 response).
248
254
  """
plain/chores/registry.py CHANGED
@@ -1,17 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from types import FunctionType
4
+ from typing import Any
5
+
1
6
  from plain.packages import packages_registry
2
7
 
3
8
 
4
9
  class Chore:
5
- def __init__(self, *, group, func):
10
+ def __init__(self, *, group: str, func: FunctionType):
6
11
  self.group = group
7
12
  self.func = func
8
13
  self.name = f"{group}.{func.__name__}"
9
14
  self.description = func.__doc__.strip() if func.__doc__ else ""
10
15
 
11
- def __str__(self):
16
+ def __str__(self) -> str:
12
17
  return self.name
13
18
 
14
- def run(self):
19
+ def run(self) -> Any:
15
20
  """
16
21
  Run the chore.
17
22
  """
@@ -20,21 +25,21 @@ class Chore:
20
25
 
21
26
  class ChoresRegistry:
22
27
  def __init__(self):
23
- self._chores = {}
28
+ self._chores: dict[FunctionType, Chore] = {}
24
29
 
25
- def register_chore(self, chore):
30
+ def register_chore(self, chore: Chore) -> None:
26
31
  """
27
32
  Register a chore with the specified name.
28
33
  """
29
34
  self._chores[chore.func] = chore
30
35
 
31
- def import_modules(self):
36
+ def import_modules(self) -> None:
32
37
  """
33
38
  Import modules from installed packages and app to trigger registration.
34
39
  """
35
40
  packages_registry.autodiscover_modules("chores", include_app=True)
36
41
 
37
- def get_chores(self):
42
+ def get_chores(self) -> list[Chore]:
38
43
  """
39
44
  Get all registered chores.
40
45
  """
@@ -44,7 +49,7 @@ class ChoresRegistry:
44
49
  chores_registry = ChoresRegistry()
45
50
 
46
51
 
47
- def register_chore(group):
52
+ def register_chore(group: str) -> Any:
48
53
  """
49
54
  Register a chore with a given group.
50
55
 
@@ -54,7 +59,7 @@ def register_chore(group):
54
59
  pass
55
60
  """
56
61
 
57
- def wrapper(func):
62
+ def wrapper(func: FunctionType) -> FunctionType:
58
63
  chore = Chore(group=group, func=func)
59
64
  chores_registry.register_chore(chore)
60
65
  return func
@@ -7,7 +7,7 @@ from .request import request
7
7
 
8
8
  @click.group("agent", invoke_without_command=True)
9
9
  @click.pass_context
10
- def agent(ctx):
10
+ def agent(ctx: click.Context) -> None:
11
11
  """Tools for coding agents."""
12
12
  if ctx.invoked_subcommand is None:
13
13
  # If no subcommand provided, show all AGENTS.md files
plain/cli/agent/docs.py CHANGED
@@ -15,7 +15,7 @@ from .llmdocs import LLMDocs
15
15
  is_flag=True,
16
16
  help="List available packages",
17
17
  )
18
- def docs(package, show_list):
18
+ def docs(package: str, show_list: bool) -> None:
19
19
  """Show LLM-friendly documentation and source for a package."""
20
20
 
21
21
  if show_list:
@@ -33,11 +33,12 @@ def docs(package, show_list):
33
33
  available_packages.append("plain")
34
34
 
35
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)
36
+ if hasattr(plain, "__path__"):
37
+ for importer, modname, ispkg in pkgutil.iter_modules(
38
+ plain.__path__, "plain."
39
+ ):
40
+ if ispkg:
41
+ available_packages.append(modname)
41
42
  except Exception:
42
43
  pass
43
44
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import ast
2
4
  from pathlib import Path
3
5
 
@@ -7,10 +9,10 @@ import click
7
9
  class LLMDocs:
8
10
  """Generates LLM-friendly documentation."""
9
11
 
10
- def __init__(self, paths):
12
+ def __init__(self, paths: list[Path]):
11
13
  self.paths = paths
12
14
 
13
- def load(self):
15
+ def load(self) -> None:
14
16
  self.docs = set()
15
17
  self.sources = set()
16
18
 
@@ -47,7 +49,7 @@ class LLMDocs:
47
49
  self.docs = sorted(self.docs)
48
50
  self.sources = sorted(self.sources)
49
51
 
50
- def display_path(self, path):
52
+ def display_path(self, path: Path) -> Path:
51
53
  if "plain" in path.parts:
52
54
  root_index = path.parts.index("plain")
53
55
  elif "plainx" in path.parts:
@@ -58,7 +60,7 @@ class LLMDocs:
58
60
  plain_root = Path(*path.parts[: root_index + 1])
59
61
  return path.relative_to(plain_root.parent)
60
62
 
61
- def print(self, relative_to=None):
63
+ def print(self, relative_to: Path | None = None) -> None:
62
64
  for doc in self.docs:
63
65
  if relative_to:
64
66
  display_path = doc.relative_to(relative_to)
@@ -81,7 +83,7 @@ class LLMDocs:
81
83
  click.echo()
82
84
 
83
85
  @staticmethod
84
- def symbolicate(file_path: Path):
86
+ def symbolicate(file_path: Path) -> str:
85
87
  if "internal" in str(file_path).split("/"):
86
88
  return ""
87
89
 
@@ -89,8 +91,16 @@ class LLMDocs:
89
91
 
90
92
  parsed = ast.parse(source)
91
93
 
92
- def should_skip(node):
93
- if isinstance(node, ast.ClassDef | ast.FunctionDef):
94
+ def should_skip(node: ast.AST) -> bool:
95
+ if isinstance(node, ast.ClassDef):
96
+ if any(
97
+ isinstance(d, ast.Name) and d.id == "internalcode"
98
+ for d in node.decorator_list
99
+ ):
100
+ return True
101
+ if node.name.startswith("_"):
102
+ return True
103
+ elif isinstance(node, ast.FunctionDef):
94
104
  if any(
95
105
  isinstance(d, ast.Name) and d.id == "internalcode"
96
106
  for d in node.decorator_list
@@ -104,7 +114,7 @@ class LLMDocs:
104
114
  return True
105
115
  return False
106
116
 
107
- def process_node(node, indent=0):
117
+ def process_node(node: ast.AST, indent: int = 0) -> list[str]:
108
118
  lines = []
109
119
  prefix = " " * indent
110
120
 
plain/cli/agent/md.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib.util
2
4
  import pkgutil
3
5
  from pathlib import Path
@@ -7,7 +9,7 @@ import click
7
9
  from ..output import iterate_markdown
8
10
 
9
11
 
10
- def _get_packages_with_agents():
12
+ def _get_packages_with_agents() -> dict[str, Path]:
11
13
  """Get dict mapping package names to AGENTS.md paths."""
12
14
  agents_files = {}
13
15
 
@@ -25,18 +27,21 @@ def _get_packages_with_agents():
25
27
  agents_files["plain"] = agents_path
26
28
 
27
29
  # 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
30
+ if hasattr(plain, "__path__"):
31
+ for importer, modname, ispkg in pkgutil.iter_modules(
32
+ plain.__path__, "plain."
33
+ ):
34
+ if ispkg:
35
+ try:
36
+ spec = importlib.util.find_spec(modname)
37
+ if spec and spec.origin:
38
+ package_path = Path(spec.origin).parent
39
+ # Look for AGENTS.md at package root
40
+ agents_path = package_path / "AGENTS.md"
41
+ if agents_path.exists():
42
+ agents_files[modname] = agents_path
43
+ except Exception:
44
+ continue
40
45
  except Exception:
41
46
  pass
42
47
 
@@ -57,7 +62,7 @@ def _get_packages_with_agents():
57
62
  is_flag=True,
58
63
  help="List packages with AGENTS.md files",
59
64
  )
60
- def md(package, show_all, show_list):
65
+ def md(package: str, show_all: bool, show_list: bool) -> None:
61
66
  """Show AGENTS.md for a package."""
62
67
 
63
68
  agents_files = _get_packages_with_agents()
plain/cli/agent/prompt.py CHANGED
@@ -5,7 +5,7 @@ import subprocess
5
5
  import click
6
6
 
7
7
 
8
- def is_agent_environment():
8
+ def is_agent_environment() -> bool:
9
9
  """Check if we're running inside a coding agent."""
10
10
  return bool(
11
11
  os.environ.get("CLAUDECODE")