textprompts 0.0.1__tar.gz → 0.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: textprompts
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Minimal text-based prompt-loader with TOML front-matter
5
5
  Keywords: prompts,toml,frontmatter,template
6
6
  Author: Jan Siml
@@ -24,6 +24,13 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  # textprompts
26
26
 
27
+ [![PyPI version](https://img.shields.io/pypi/v/textprompts.svg)](https://pypi.org/project/textprompts/)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/textprompts.svg)](https://pypi.org/project/textprompts/)
29
+ [![CI status](https://github.com/svilupp/textprompts/workflows/CI/badge.svg)](https://github.com/svilupp/textprompts/actions)
30
+ [![Coverage](https://img.shields.io/codecov/c/github/svilupp/textprompts)](https://codecov.io/gh/svilupp/textprompts)
31
+ [![License](https://img.shields.io/pypi/l/textprompts.svg)](https://github.com/svilupp/textprompts/blob/main/LICENSE)
32
+
33
+
27
34
  > **So simple, it's not even worth vibing about coding yet it just makes so much sense.**
28
35
 
29
36
  Are you tired of vendors trying to sell you fancy UIs for prompt management that just make your system more confusing and harder to debug? Isn't it nice to just have your prompts **next to your code**?
@@ -59,7 +66,6 @@ title = "Customer Greeting"
59
66
  version = "1.0.0"
60
67
  description = "Friendly greeting for customer support"
61
68
  ---
62
-
63
69
  Hello {customer_name}!
64
70
 
65
71
  Welcome to {company_name}. We're here to help you with {issue_type}.
@@ -74,9 +80,11 @@ import textprompts
74
80
 
75
81
  # Just load it - works with or without metadata
76
82
  prompt = textprompts.load_prompt("greeting.txt")
83
+ # Or simply
84
+ alt = textprompts.Prompt("greeting.txt")
77
85
 
78
86
  # Use it safely - all placeholders must be provided
79
- message = prompt.body.format(
87
+ message = prompt.prompt.format(
80
88
  customer_name="Alice",
81
89
  company_name="ACME Corp",
82
90
  issue_type="billing question",
@@ -86,19 +94,22 @@ message = prompt.body.format(
86
94
  print(message)
87
95
 
88
96
  # Or use partial formatting when needed
89
- partial = prompt.body.format(
97
+ partial = prompt.prompt.format(
90
98
  customer_name="Alice",
91
99
  company_name="ACME Corp",
92
100
  skip_validation=True
93
101
  )
94
102
  # Result: "Hello Alice!\n\nWelcome to ACME Corp. We're here to help you with {issue_type}.\n\nBest regards,\n{agent_name}"
103
+
104
+ # Prompt objects expose `.meta` and `.prompt`.
105
+ # Use `prompt.prompt.format()` for safe formatting or `str(prompt)` for raw text.
95
106
  ```
96
107
 
97
108
  **Even simpler** - no metadata required:
98
109
  ```python
99
110
  # simple_prompt.txt contains just: "Analyze this data: {data}"
100
111
  prompt = textprompts.load_prompt("simple_prompt.txt") # Just works!
101
- result = prompt.body.format(data="sales figures")
112
+ result = prompt.prompt.format(data="sales figures")
102
113
  ```
103
114
 
104
115
  ## Core Features
@@ -108,9 +119,9 @@ result = prompt.body.format(data="sales figures")
108
119
  Never ship a prompt with missing variables again:
109
120
 
110
121
  ```python
111
- from textprompts import SafeString
122
+ from textprompts import PromptString
112
123
 
113
- template = SafeString("Hello {name}, your order {order_id} is {status}")
124
+ template = PromptString("Hello {name}, your order {order_id} is {status}")
114
125
 
115
126
  # ✅ Strict formatting - all placeholders must be provided
116
127
  result = template.format(name="Alice", order_id="12345", status="shipped")
@@ -190,14 +201,14 @@ response = openai.chat.completions.create(
190
201
  messages=[
191
202
  {
192
203
  "role": "system",
193
- "content": system_prompt.body.format(
204
+ "content": system_prompt.prompt.format(
194
205
  company_name="ACME Corp",
195
206
  support_level="premium"
196
207
  )
197
208
  },
198
209
  {
199
210
  "role": "user",
200
- "content": user_prompt.body.format(
211
+ "content": user_prompt.prompt.format(
201
212
  query="How do I return an item?",
202
213
  customer_tier="premium"
203
214
  )
@@ -208,7 +219,7 @@ response = openai.chat.completions.create(
208
219
 
209
220
  ### Function Calling (Tool Definitions)
210
221
 
211
- Yes, you can version control your function schemas too:
222
+ Yes, you can version control your whole tool schemas too:
212
223
 
213
224
  ```python
214
225
  # tools/search_products.txt
@@ -217,7 +228,6 @@ title = "Product Search Tool"
217
228
  version = "2.1.0"
218
229
  description = "Search our product catalog"
219
230
  ---
220
-
221
231
  {
222
232
  "type": "function",
223
233
  "function": {
@@ -253,7 +263,7 @@ from textprompts import load_prompt
253
263
 
254
264
  # Load and parse the tool definition
255
265
  tool_prompt = load_prompt("tools/search_products.txt")
256
- tool_schema = json.loads(tool_prompt.body)
266
+ tool_schema = json.loads(tool_prompt.prompt)
257
267
 
258
268
  # Use with OpenAI
259
269
  response = openai.chat.completions.create(
@@ -303,7 +313,6 @@ description = "What this prompt does"
303
313
  created = "2024-01-15"
304
314
  tags = ["customer-support", "greeting"]
305
315
  ---
306
-
307
316
  Your prompt content goes here.
308
317
 
309
318
  Use {variables} for templating.
@@ -317,6 +326,10 @@ Choose the right level of strictness for your use case:
317
326
  2. **ALLOW** - Load metadata if present, don't worry about completeness
318
327
  3. **STRICT** - Require complete metadata (title, description, version) for production safety
319
328
 
329
+ You can also set the environment variable `TEXTPROMPTS_METADATA_MODE` to one of
330
+ `strict`, `allow`, or `ignore` before importing the library to configure the
331
+ default mode.
332
+
320
333
  ```python
321
334
  # Set globally
322
335
  textprompts.set_metadata("ignore") # Default: simple file loading
@@ -338,7 +351,7 @@ Load a single prompt file.
338
351
 
339
352
  Returns a `Prompt` object with:
340
353
  - `prompt.meta`: Metadata from TOML front-matter (always present)
341
- - `prompt.body`: The prompt content as a `SafeString`
354
+ - `prompt.prompt`: The prompt content as a `PromptString`
342
355
  - `prompt.path`: Path to the original file
343
356
 
344
357
  ### `load_prompts(*paths, recursive=False, glob="*.txt", meta=None, max_files=1000)`
@@ -385,14 +398,14 @@ save_prompt("my_prompt.txt", "You are a helpful assistant.")
385
398
  save_prompt("my_prompt.txt", prompt_object)
386
399
  ```
387
400
 
388
- ### `SafeString`
401
+ ### `PromptString`
389
402
 
390
403
  A string subclass that validates `format()` calls:
391
404
 
392
405
  ```python
393
- from textprompts import SafeString
406
+ from textprompts import PromptString
394
407
 
395
- template = SafeString("Hello {name}, you are {role}")
408
+ template = PromptString("Hello {name}, you are {role}")
396
409
 
397
410
  # Strict formatting (default) - all placeholders required
398
411
  result = template.format(name="Alice", role="admin") # ✅ Works
@@ -460,8 +473,8 @@ textprompts validate prompts/
460
473
  4. **Test your prompts**: Write unit tests for critical prompts
461
474
  ```python
462
475
  def test_greeting_prompt():
463
- prompt = load_prompt("greeting.txt")
464
- result = prompt.body.format(customer_name="Test")
476
+ prompt = load_prompt("greeting.txt")
477
+ result = prompt.prompt.format(customer_name="Test")
465
478
  assert "Test" in result
466
479
  ```
467
480
 
@@ -1,5 +1,12 @@
1
1
  # textprompts
2
2
 
3
+ [![PyPI version](https://img.shields.io/pypi/v/textprompts.svg)](https://pypi.org/project/textprompts/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/textprompts.svg)](https://pypi.org/project/textprompts/)
5
+ [![CI status](https://github.com/svilupp/textprompts/workflows/CI/badge.svg)](https://github.com/svilupp/textprompts/actions)
6
+ [![Coverage](https://img.shields.io/codecov/c/github/svilupp/textprompts)](https://codecov.io/gh/svilupp/textprompts)
7
+ [![License](https://img.shields.io/pypi/l/textprompts.svg)](https://github.com/svilupp/textprompts/blob/main/LICENSE)
8
+
9
+
3
10
  > **So simple, it's not even worth vibing about coding yet it just makes so much sense.**
4
11
 
5
12
  Are you tired of vendors trying to sell you fancy UIs for prompt management that just make your system more confusing and harder to debug? Isn't it nice to just have your prompts **next to your code**?
@@ -35,7 +42,6 @@ title = "Customer Greeting"
35
42
  version = "1.0.0"
36
43
  description = "Friendly greeting for customer support"
37
44
  ---
38
-
39
45
  Hello {customer_name}!
40
46
 
41
47
  Welcome to {company_name}. We're here to help you with {issue_type}.
@@ -50,9 +56,11 @@ import textprompts
50
56
 
51
57
  # Just load it - works with or without metadata
52
58
  prompt = textprompts.load_prompt("greeting.txt")
59
+ # Or simply
60
+ alt = textprompts.Prompt("greeting.txt")
53
61
 
54
62
  # Use it safely - all placeholders must be provided
55
- message = prompt.body.format(
63
+ message = prompt.prompt.format(
56
64
  customer_name="Alice",
57
65
  company_name="ACME Corp",
58
66
  issue_type="billing question",
@@ -62,19 +70,22 @@ message = prompt.body.format(
62
70
  print(message)
63
71
 
64
72
  # Or use partial formatting when needed
65
- partial = prompt.body.format(
73
+ partial = prompt.prompt.format(
66
74
  customer_name="Alice",
67
75
  company_name="ACME Corp",
68
76
  skip_validation=True
69
77
  )
70
78
  # Result: "Hello Alice!\n\nWelcome to ACME Corp. We're here to help you with {issue_type}.\n\nBest regards,\n{agent_name}"
79
+
80
+ # Prompt objects expose `.meta` and `.prompt`.
81
+ # Use `prompt.prompt.format()` for safe formatting or `str(prompt)` for raw text.
71
82
  ```
72
83
 
73
84
  **Even simpler** - no metadata required:
74
85
  ```python
75
86
  # simple_prompt.txt contains just: "Analyze this data: {data}"
76
87
  prompt = textprompts.load_prompt("simple_prompt.txt") # Just works!
77
- result = prompt.body.format(data="sales figures")
88
+ result = prompt.prompt.format(data="sales figures")
78
89
  ```
79
90
 
80
91
  ## Core Features
@@ -84,9 +95,9 @@ result = prompt.body.format(data="sales figures")
84
95
  Never ship a prompt with missing variables again:
85
96
 
86
97
  ```python
87
- from textprompts import SafeString
98
+ from textprompts import PromptString
88
99
 
89
- template = SafeString("Hello {name}, your order {order_id} is {status}")
100
+ template = PromptString("Hello {name}, your order {order_id} is {status}")
90
101
 
91
102
  # ✅ Strict formatting - all placeholders must be provided
92
103
  result = template.format(name="Alice", order_id="12345", status="shipped")
@@ -166,14 +177,14 @@ response = openai.chat.completions.create(
166
177
  messages=[
167
178
  {
168
179
  "role": "system",
169
- "content": system_prompt.body.format(
180
+ "content": system_prompt.prompt.format(
170
181
  company_name="ACME Corp",
171
182
  support_level="premium"
172
183
  )
173
184
  },
174
185
  {
175
186
  "role": "user",
176
- "content": user_prompt.body.format(
187
+ "content": user_prompt.prompt.format(
177
188
  query="How do I return an item?",
178
189
  customer_tier="premium"
179
190
  )
@@ -184,7 +195,7 @@ response = openai.chat.completions.create(
184
195
 
185
196
  ### Function Calling (Tool Definitions)
186
197
 
187
- Yes, you can version control your function schemas too:
198
+ Yes, you can version control your whole tool schemas too:
188
199
 
189
200
  ```python
190
201
  # tools/search_products.txt
@@ -193,7 +204,6 @@ title = "Product Search Tool"
193
204
  version = "2.1.0"
194
205
  description = "Search our product catalog"
195
206
  ---
196
-
197
207
  {
198
208
  "type": "function",
199
209
  "function": {
@@ -229,7 +239,7 @@ from textprompts import load_prompt
229
239
 
230
240
  # Load and parse the tool definition
231
241
  tool_prompt = load_prompt("tools/search_products.txt")
232
- tool_schema = json.loads(tool_prompt.body)
242
+ tool_schema = json.loads(tool_prompt.prompt)
233
243
 
234
244
  # Use with OpenAI
235
245
  response = openai.chat.completions.create(
@@ -279,7 +289,6 @@ description = "What this prompt does"
279
289
  created = "2024-01-15"
280
290
  tags = ["customer-support", "greeting"]
281
291
  ---
282
-
283
292
  Your prompt content goes here.
284
293
 
285
294
  Use {variables} for templating.
@@ -293,6 +302,10 @@ Choose the right level of strictness for your use case:
293
302
  2. **ALLOW** - Load metadata if present, don't worry about completeness
294
303
  3. **STRICT** - Require complete metadata (title, description, version) for production safety
295
304
 
305
+ You can also set the environment variable `TEXTPROMPTS_METADATA_MODE` to one of
306
+ `strict`, `allow`, or `ignore` before importing the library to configure the
307
+ default mode.
308
+
296
309
  ```python
297
310
  # Set globally
298
311
  textprompts.set_metadata("ignore") # Default: simple file loading
@@ -314,7 +327,7 @@ Load a single prompt file.
314
327
 
315
328
  Returns a `Prompt` object with:
316
329
  - `prompt.meta`: Metadata from TOML front-matter (always present)
317
- - `prompt.body`: The prompt content as a `SafeString`
330
+ - `prompt.prompt`: The prompt content as a `PromptString`
318
331
  - `prompt.path`: Path to the original file
319
332
 
320
333
  ### `load_prompts(*paths, recursive=False, glob="*.txt", meta=None, max_files=1000)`
@@ -361,14 +374,14 @@ save_prompt("my_prompt.txt", "You are a helpful assistant.")
361
374
  save_prompt("my_prompt.txt", prompt_object)
362
375
  ```
363
376
 
364
- ### `SafeString`
377
+ ### `PromptString`
365
378
 
366
379
  A string subclass that validates `format()` calls:
367
380
 
368
381
  ```python
369
- from textprompts import SafeString
382
+ from textprompts import PromptString
370
383
 
371
- template = SafeString("Hello {name}, you are {role}")
384
+ template = PromptString("Hello {name}, you are {role}")
372
385
 
373
386
  # Strict formatting (default) - all placeholders required
374
387
  result = template.format(name="Alice", role="admin") # ✅ Works
@@ -436,8 +449,8 @@ textprompts validate prompts/
436
449
  4. **Test your prompts**: Write unit tests for critical prompts
437
450
  ```python
438
451
  def test_greeting_prompt():
439
- prompt = load_prompt("greeting.txt")
440
- result = prompt.body.format(customer_name="Test")
452
+ prompt = load_prompt("greeting.txt")
453
+ result = prompt.prompt.format(customer_name="Test")
441
454
  assert "Test" in result
442
455
  ```
443
456
 
@@ -1,9 +1,10 @@
1
1
  [project]
2
2
  name = "textprompts"
3
- version = "0.0.1"
3
+ version = "0.0.3"
4
4
  description = "Minimal text-based prompt-loader with TOML front-matter"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
+ license-file = "LICENSE"
7
8
  authors = [
8
9
  {name = "Jan Siml", email = "49557684+svilupp@users.noreply.github.com"},
9
10
  ]
@@ -37,6 +38,9 @@ dev = [
37
38
  "ruff>=0.12.2",
38
39
  "pre-commit>=3.0.0",
39
40
  ]
41
+ test = [
42
+ "pydantic-ai>=0.4.5",
43
+ ]
40
44
 
41
45
  [build-system]
42
46
  requires = ["uv_build>=0.7.19,<0.8.0"]
@@ -1,4 +1,10 @@
1
- from .config import MetadataMode, get_metadata, set_metadata
1
+ from .config import (
2
+ MetadataMode,
3
+ get_metadata,
4
+ set_metadata,
5
+ skip_metadata,
6
+ warn_on_ignored_metadata,
7
+ )
2
8
  from .errors import (
3
9
  FileMissingError,
4
10
  InvalidMetadataError,
@@ -8,7 +14,7 @@ from .errors import (
8
14
  )
9
15
  from .loaders import load_prompt, load_prompts
10
16
  from .models import Prompt, PromptMeta
11
- from .safe_string import SafeString
17
+ from .prompt_string import PromptString, SafeString
12
18
  from .savers import save_prompt
13
19
 
14
20
  __all__ = [
@@ -17,10 +23,13 @@ __all__ = [
17
23
  "save_prompt",
18
24
  "Prompt",
19
25
  "PromptMeta",
26
+ "PromptString",
20
27
  "SafeString",
21
28
  "MetadataMode",
22
29
  "set_metadata",
23
30
  "get_metadata",
31
+ "skip_metadata",
32
+ "warn_on_ignored_metadata",
24
33
  "TextPromptsError",
25
34
  "FileMissingError",
26
35
  "MissingMetadataError",
@@ -0,0 +1,8 @@
1
+ """Command-line entry point for ``python -m textprompts``.""" # pragma: no cover
2
+
3
+ from textprompts.cli import main # pragma: no cover
4
+
5
+ __all__ = ["main"] # pragma: no cover
6
+
7
+ if __name__ == "__main__": # pragma: no cover - small entrypoint wrapper
8
+ main() # pragma: no cover
@@ -4,13 +4,13 @@ from typing import Optional
4
4
 
5
5
  try:
6
6
  import tomllib
7
- except ImportError:
7
+ except ImportError: # pragma: no cover - Python <3.11 fallback
8
8
  import tomli as tomllib # type: ignore[import-not-found, no-redef]
9
9
 
10
- from .config import MetadataMode
10
+ from .config import MetadataMode, warn_on_ignored_metadata
11
11
  from .errors import InvalidMetadataError, MalformedHeaderError, MissingMetadataError
12
12
  from .models import Prompt, PromptMeta
13
- from .safe_string import SafeString
13
+ from .prompt_string import PromptString
14
14
 
15
15
  DELIM = "---"
16
16
 
@@ -59,8 +59,25 @@ def parse_file(path: Path, *, metadata_mode: MetadataMode) -> Prompt:
59
59
 
60
60
  # Handle IGNORE mode - treat entire file as body
61
61
  if metadata_mode == MetadataMode.IGNORE:
62
+ if (
63
+ warn_on_ignored_metadata()
64
+ and raw.startswith(DELIM)
65
+ and raw.find(DELIM, len(DELIM)) != -1
66
+ ):
67
+ import warnings
68
+
69
+ warnings.warn(
70
+ "Metadata detected but ignored; use set_metadata('allow') or skip_metadata(skip_warning=True) to silence",
71
+ stacklevel=2,
72
+ )
62
73
  ignore_meta = PromptMeta(title=path.stem)
63
- return Prompt(path=path, meta=ignore_meta, body=SafeString(textwrap.dedent(raw)))
74
+ return Prompt.model_validate(
75
+ {
76
+ "path": path,
77
+ "meta": ignore_meta,
78
+ "prompt": PromptString(textwrap.dedent(raw)),
79
+ }
80
+ )
64
81
 
65
82
  # For STRICT and ALLOW modes, try to parse front matter
66
83
  try:
@@ -72,7 +89,7 @@ def parse_file(path: Path, *, metadata_mode: MetadataMode) -> Prompt:
72
89
  f"{e}. If this file has no metadata and starts with '---', "
73
90
  f"use meta=MetadataMode.IGNORE to skip metadata parsing."
74
91
  ) from e
75
- raise
92
+ raise # pragma: no cover - reraised to preserve stack
76
93
 
77
94
  meta: Optional[PromptMeta] = None
78
95
  if header_txt is not None:
@@ -114,7 +131,7 @@ def parse_file(path: Path, *, metadata_mode: MetadataMode) -> Prompt:
114
131
  ) from e
115
132
  except InvalidMetadataError:
116
133
  raise
117
- except Exception as e:
134
+ except Exception as e: # pragma: no cover - unlikely generic error
118
135
  raise InvalidMetadataError(f"Invalid metadata: {e}") from e
119
136
 
120
137
  else:
@@ -129,11 +146,17 @@ def parse_file(path: Path, *, metadata_mode: MetadataMode) -> Prompt:
129
146
  meta = PromptMeta()
130
147
 
131
148
  # Always ensure we have metadata with a title
132
- if not meta:
149
+ if not meta: # pragma: no cover - meta is never falsy but kept for safety
133
150
  meta = PromptMeta()
134
151
 
135
152
  # Use filename as title if not provided
136
153
  if meta.title is None:
137
154
  meta.title = path.stem
138
155
 
139
- return Prompt(path=path, meta=meta, body=SafeString(textwrap.dedent(body)))
156
+ return Prompt.model_validate(
157
+ {
158
+ "path": path,
159
+ "meta": meta,
160
+ "prompt": PromptString(textwrap.dedent(body)),
161
+ }
162
+ )
@@ -28,4 +28,4 @@ def main() -> None:
28
28
 
29
29
 
30
30
  if __name__ == "__main__":
31
- main()
31
+ main() # pragma: no cover - simple CLI entry point
@@ -2,6 +2,7 @@
2
2
  Global configuration for textprompts metadata handling.
3
3
  """
4
4
 
5
+ import os
5
6
  from enum import Enum
6
7
  from typing import Union
7
8
 
@@ -29,7 +30,14 @@ class MetadataMode(Enum):
29
30
 
30
31
 
31
32
  # Global configuration variable
32
- _METADATA_MODE: MetadataMode = MetadataMode.IGNORE
33
+ _env_mode = os.getenv("TEXTPROMPTS_METADATA_MODE")
34
+ try:
35
+ _METADATA_MODE: MetadataMode = (
36
+ MetadataMode(_env_mode.lower()) if _env_mode else MetadataMode.IGNORE
37
+ )
38
+ except ValueError:
39
+ _METADATA_MODE = MetadataMode.IGNORE
40
+ _WARN_ON_IGNORED_META: bool = True
33
41
 
34
42
 
35
43
  def set_metadata(mode: Union[MetadataMode, str]) -> None:
@@ -82,6 +90,20 @@ def get_metadata() -> MetadataMode:
82
90
  return _METADATA_MODE
83
91
 
84
92
 
93
+ def skip_metadata(*, skip_warning: bool = False) -> None:
94
+ """Convenience setter for ignoring metadata with optional warnings."""
95
+
96
+ global _WARN_ON_IGNORED_META
97
+ _WARN_ON_IGNORED_META = not skip_warning # pragma: no cover
98
+ set_metadata(MetadataMode.IGNORE) # pragma: no cover - simple wrapper
99
+
100
+
101
+ def warn_on_ignored_metadata() -> bool:
102
+ """Return whether warnings for ignored metadata are enabled."""
103
+
104
+ return _WARN_ON_IGNORED_META
105
+
106
+
85
107
  def _resolve_metadata_mode(meta: Union[MetadataMode, str, None]) -> MetadataMode:
86
108
  """
87
109
  Resolve the metadata mode from parameters and global config.
@@ -96,17 +96,25 @@ def load_prompts(
96
96
  if pth.is_dir():
97
97
  itr: Iterable[Path] = pth.rglob(glob) if recursive else pth.glob(glob)
98
98
  for f in itr:
99
- if max_files and file_count >= max_files:
99
+ if (
100
+ max_files and file_count >= max_files
101
+ ): # pragma: no cover - boundary check
100
102
  from .errors import TextPromptsError
101
103
 
102
- raise TextPromptsError(f"Exceeded max_files limit of {max_files}")
104
+ raise TextPromptsError(
105
+ f"Exceeded max_files limit of {max_files}"
106
+ ) # pragma: no cover - boundary check
103
107
  collected.append(load_prompt(f, meta=meta))
104
108
  file_count += 1
105
109
  else:
106
- if max_files and file_count >= max_files:
110
+ if (
111
+ max_files and file_count >= max_files
112
+ ): # pragma: no cover - boundary check
107
113
  from .errors import TextPromptsError
108
114
 
109
- raise TextPromptsError(f"Exceeded max_files limit of {max_files}")
115
+ raise TextPromptsError(
116
+ f"Exceeded max_files limit of {max_files}"
117
+ ) # pragma: no cover - boundary check
110
118
  collected.append(load_prompt(pth, meta=meta))
111
119
  file_count += 1
112
120
 
@@ -0,0 +1,85 @@
1
+ from datetime import date
2
+ from pathlib import Path
3
+ from typing import Any, Union
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+ from .config import MetadataMode
8
+ from .prompt_string import PromptString
9
+
10
+
11
+ class PromptMeta(BaseModel):
12
+ title: Union[str, None] = Field(default=None, description="Human-readable name")
13
+ version: Union[str, None] = Field(default=None)
14
+ author: Union[str, None] = Field(default=None)
15
+ created: Union[date, None] = Field(default=None)
16
+ description: Union[str, None] = Field(default=None)
17
+
18
+
19
+ class Prompt(BaseModel):
20
+ path: Path
21
+ meta: Union[PromptMeta, None]
22
+ prompt: PromptString
23
+
24
+ def __init__(
25
+ self,
26
+ path: Union[str, Path],
27
+ meta: Union[PromptMeta, MetadataMode, str, None] = None,
28
+ prompt: Union[str, PromptString, None] = None,
29
+ ) -> None:
30
+ """Initialize Prompt from fields or load from file."""
31
+ if prompt is None:
32
+ from .loaders import load_prompt
33
+
34
+ loaded = load_prompt(path, meta=meta)
35
+ super().__init__(**loaded.model_dump())
36
+ else:
37
+ if isinstance(prompt, str):
38
+ prompt = PromptString(prompt)
39
+ super().__init__(path=Path(path), meta=meta, prompt=prompt)
40
+
41
+ @field_validator("prompt")
42
+ @classmethod
43
+ def prompt_not_empty(cls, v: str) -> PromptString:
44
+ if not v.strip():
45
+ raise ValueError("Prompt body is empty")
46
+ return PromptString(v)
47
+
48
+ def __repr__(self) -> str:
49
+ if self.meta and self.meta.title:
50
+ if self.meta.version:
51
+ return (
52
+ f"Prompt(title='{self.meta.title}', version='{self.meta.version}')"
53
+ " # use .format() or str()"
54
+ )
55
+ return f"Prompt(title='{self.meta.title}') # use .format() or str()"
56
+ return f"Prompt(path='{self.path}') # use .format() or str()"
57
+
58
+ def __str__(self) -> str:
59
+ return str(self.prompt)
60
+
61
+ @property
62
+ def body(self) -> PromptString:
63
+ import warnings
64
+
65
+ warnings.warn(
66
+ "Prompt.body is deprecated; use .prompt instead",
67
+ DeprecationWarning,
68
+ stacklevel=2,
69
+ )
70
+ return self.prompt
71
+
72
+ def __len__(self) -> int:
73
+ return len(self.prompt)
74
+
75
+ def __getitem__(self, item: int | slice) -> str:
76
+ return self.prompt[item]
77
+
78
+ def __add__(self, other: str) -> str:
79
+ return str(self.prompt) + str(other)
80
+
81
+ def strip(self, *args: Any, **kwargs: Any) -> str:
82
+ return self.prompt.strip(*args, **kwargs)
83
+
84
+ def format(self, *args: Any, **kwargs: Any) -> str:
85
+ return self.prompt.format(*args, **kwargs)
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Utility functions for extracting and validating placeholders in format strings.
3
3
 
4
- This module provides robust placeholder extraction and validation for SafeString
4
+ This module provides robust placeholder extraction and validation for PromptString
5
5
  formatting operations.
6
6
  """
7
7
 
@@ -53,7 +53,10 @@ def extract_placeholders(text: str) -> Set[str]:
53
53
 
54
54
 
55
55
  def validate_format_args(
56
- placeholders: Set[str], args: Tuple[Any, ...], kwargs: Dict[str, Any], skip_validation: bool = False
56
+ placeholders: Set[str],
57
+ args: Tuple[Any, ...],
58
+ kwargs: Dict[str, Any],
59
+ skip_validation: bool = False,
57
60
  ) -> None:
58
61
  """
59
62
  Validate that format arguments match the placeholders in the template.
@@ -0,0 +1,60 @@
1
+ from typing import Any, Set
2
+
3
+ from pydantic import GetCoreSchemaHandler
4
+ from pydantic_core import core_schema
5
+
6
+ from .placeholder_utils import extract_placeholders, validate_format_args
7
+
8
+
9
+ class PromptString(str):
10
+ """String subclass that validates ``format()`` calls."""
11
+
12
+ placeholders: Set[str]
13
+
14
+ def __new__(cls, value: str) -> "PromptString":
15
+ instance = str.__new__(cls, value)
16
+ instance.placeholders = extract_placeholders(value)
17
+ return instance
18
+
19
+ def format(self, *args: Any, **kwargs: Any) -> str:
20
+ """Format with validation and optional partial formatting."""
21
+ skip_validation = kwargs.pop("skip_validation", False)
22
+ source = str(self).strip()
23
+ if skip_validation:
24
+ return self._partial_format(*args, source=source, **kwargs)
25
+ validate_format_args(self.placeholders, args, kwargs, skip_validation=False)
26
+ return str.format(source, *args, **kwargs)
27
+
28
+ def _partial_format(
29
+ self, *args: Any, source: str | None = None, **kwargs: Any
30
+ ) -> str:
31
+ """Partial formatting - replace placeholders that have values."""
32
+ all_kwargs = kwargs.copy()
33
+ for i, arg in enumerate(args):
34
+ all_kwargs[str(i)] = arg
35
+
36
+ result = source if source is not None else str(self)
37
+ for placeholder in self.placeholders:
38
+ if placeholder in all_kwargs:
39
+ pattern = f"{{{placeholder}}}"
40
+ if pattern in result:
41
+ try:
42
+ result = result.replace(pattern, str(all_kwargs[placeholder]))
43
+ except (KeyError, ValueError): # pragma: no cover - defensive
44
+ pass
45
+ return result
46
+
47
+ def __repr__(self) -> str:
48
+ return f"PromptString({str.__repr__(self)}, placeholders={self.placeholders})"
49
+
50
+ @classmethod
51
+ def __get_pydantic_core_schema__(
52
+ cls, source_type: Any, handler: GetCoreSchemaHandler
53
+ ) -> core_schema.CoreSchema:
54
+ return core_schema.no_info_after_validator_function(
55
+ cls, core_schema.str_schema()
56
+ )
57
+
58
+
59
+ # Backwards compatibility alias
60
+ SafeString = PromptString
@@ -0,0 +1,5 @@
1
+ """Backward-compatible alias module.""" # pragma: no cover
2
+
3
+ from .prompt_string import PromptString, SafeString # pragma: no cover
4
+
5
+ __all__ = ["PromptString", "SafeString"] # pragma: no cover
@@ -25,7 +25,7 @@ def save_prompt(path: Union[str, Path], content: Union[str, Prompt]) -> None:
25
25
  >>> prompt = Prompt(
26
26
  ... path=Path("my_prompt.txt"),
27
27
  ... meta=PromptMeta(title="Assistant", version="1.0.0", description="A helpful AI"),
28
- ... body="You are a helpful assistant."
28
+ ... prompt="You are a helpful assistant."
29
29
  ... )
30
30
  >>> save_prompt("my_prompt.txt", prompt)
31
31
  """
@@ -59,7 +59,7 @@ version = ""
59
59
 
60
60
  lines.append("---")
61
61
  lines.append("")
62
- lines.append(str(content.body))
62
+ lines.append(str(content.prompt))
63
63
 
64
64
  path.write_text("\n".join(lines), encoding="utf-8")
65
65
  else:
@@ -1,42 +0,0 @@
1
- from datetime import date
2
- from pathlib import Path
3
- from typing import Union
4
-
5
- from pydantic import BaseModel, Field, field_validator
6
-
7
- from .safe_string import SafeString
8
-
9
-
10
- class PromptMeta(BaseModel):
11
- title: Union[str, None] = Field(default=None, description="Human-readable name")
12
- version: Union[str, None] = Field(default=None)
13
- author: Union[str, None] = Field(default=None)
14
- created: Union[date, None] = Field(default=None)
15
- description: Union[str, None] = Field(default=None)
16
-
17
-
18
- class Prompt(BaseModel):
19
- path: Path
20
- meta: Union[PromptMeta, None]
21
- body: SafeString
22
-
23
- @field_validator("body")
24
- @classmethod
25
- def body_not_empty(cls, v: str) -> SafeString:
26
- if not v.strip():
27
- raise ValueError("Prompt body is empty")
28
- return SafeString(v)
29
-
30
- def __repr__(self) -> str:
31
- if self.meta and self.meta.title:
32
- if self.meta.version:
33
- return (
34
- f"Prompt(title='{self.meta.title}', version='{self.meta.version}')"
35
- )
36
- else:
37
- return f"Prompt(title='{self.meta.title}')"
38
- else:
39
- return f"Prompt(path='{self.path}')"
40
-
41
- def __str__(self) -> str:
42
- return str(self.body)
@@ -1,110 +0,0 @@
1
- from typing import Any, Set
2
-
3
- from pydantic import GetCoreSchemaHandler
4
- from pydantic_core import core_schema
5
-
6
- from .placeholder_utils import extract_placeholders, validate_format_args
7
-
8
-
9
- class SafeString(str):
10
- """
11
- A string subclass that validates format() calls to ensure all placeholders are provided.
12
-
13
- This prevents common errors where format variables are missing, making prompt templates
14
- more reliable and easier to debug.
15
-
16
- Attributes:
17
- placeholders: Set of placeholder names found in the string
18
- """
19
-
20
- placeholders: Set[str]
21
-
22
- def __new__(cls, value: str) -> "SafeString":
23
- """Create a new SafeString instance with extracted placeholders."""
24
- instance = str.__new__(cls, value)
25
- instance.placeholders = extract_placeholders(value)
26
- return instance
27
-
28
- def format(self, *args: Any, **kwargs: Any) -> str:
29
- """
30
- Format the string with configurable validation behavior.
31
-
32
- By default (skip_validation=False), this method validates that all placeholders
33
- have corresponding values and raises ValueError if any are missing.
34
-
35
- When skip_validation=True, it performs partial formatting, replacing only
36
- the placeholders for which values are provided.
37
-
38
- Args:
39
- *args: Positional arguments for formatting
40
- skip_validation: If True, perform partial formatting without validation
41
- **kwargs: Keyword arguments for formatting
42
-
43
- Returns:
44
- The formatted string
45
-
46
- Raises:
47
- ValueError: If skip_validation=False and any placeholder is missing
48
- """
49
- skip_validation = kwargs.pop('skip_validation', False)
50
- if skip_validation:
51
- # Partial formatting - replace only available placeholders
52
- return self._partial_format(*args, **kwargs)
53
- else:
54
- # Strict formatting - validate all placeholders are provided
55
- validate_format_args(self.placeholders, args, kwargs, skip_validation=False)
56
- return str.format(self, *args, **kwargs)
57
-
58
- def _partial_format(self, *args: Any, **kwargs: Any) -> str:
59
- """
60
- Perform partial formatting, replacing only the placeholders that have values.
61
-
62
- Args:
63
- *args: Positional arguments for formatting
64
- **kwargs: Keyword arguments for formatting
65
-
66
- Returns:
67
- The partially formatted string
68
- """
69
- # Convert positional args to keyword args
70
- all_kwargs = kwargs.copy()
71
- for i, arg in enumerate(args):
72
- all_kwargs[str(i)] = arg
73
-
74
- # Build a format string with only available placeholders
75
- result = str(self)
76
-
77
- # Replace placeholders one by one if they have values
78
- for placeholder in self.placeholders:
79
- if placeholder in all_kwargs:
80
- # Create a single-placeholder format string
81
- placeholder_pattern = f"{{{placeholder}}}"
82
- if placeholder_pattern in result:
83
- try:
84
- # Replace this specific placeholder
85
- result = result.replace(
86
- placeholder_pattern, str(all_kwargs[placeholder])
87
- )
88
- except (KeyError, ValueError):
89
- # If replacement fails, leave the placeholder as is
90
- pass
91
-
92
- return result
93
-
94
- def __str__(self) -> str:
95
- """Return the string representation."""
96
- return str.__str__(self)
97
-
98
- def __repr__(self) -> str:
99
- """Return the string representation for debugging."""
100
- return f"SafeString({str.__repr__(self)}, placeholders={self.placeholders})"
101
-
102
- @classmethod
103
- def __get_pydantic_core_schema__(
104
- cls, source_type: Any, handler: GetCoreSchemaHandler
105
- ) -> core_schema.CoreSchema:
106
- """Support for Pydantic v2 schema generation."""
107
- return core_schema.no_info_after_validator_function(
108
- cls,
109
- core_schema.str_schema(),
110
- )