docwright 0.1.2__tar.gz → 0.1.4__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.
Files changed (42) hide show
  1. {docwright-0.1.2 → docwright-0.1.4}/PKG-INFO +1 -1
  2. docwright-0.1.4/docwright/built_in_templates/wiki/home.md.j2 +20 -0
  3. {docwright-0.1.2 → docwright-0.1.4}/docwright/config.py +1 -0
  4. docwright-0.1.4/docwright/engine.py +290 -0
  5. {docwright-0.1.2 → docwright-0.1.4}/pyproject.toml +1 -1
  6. docwright-0.1.2/docwright/engine.py +0 -165
  7. {docwright-0.1.2 → docwright-0.1.4}/LICENSE +0 -0
  8. {docwright-0.1.2 → docwright-0.1.4}/README.md +0 -0
  9. {docwright-0.1.2 → docwright-0.1.4}/docwright/__init__.py +0 -0
  10. {docwright-0.1.2 → docwright-0.1.4}/docwright/analyzer.py +0 -0
  11. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/__init__.py +0 -0
  12. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/readme/__init__.py +0 -0
  13. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/readme/default.md.j2 +0 -0
  14. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/__init__.py +0 -0
  15. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/adr.md.j2 +0 -0
  16. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/api-contracts.md.j2 +0 -0
  17. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/architecture.md.j2 +0 -0
  18. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/data-model.md.j2 +0 -0
  19. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/db-schema.md.j2 +0 -0
  20. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/development-guide.md.j2 +0 -0
  21. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/integrations.md.j2 +0 -0
  22. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/operations.md.j2 +0 -0
  23. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/security.md.j2 +0 -0
  24. {docwright-0.1.2 → docwright-0.1.4}/docwright/built_in_templates/wiki/troubleshooting.md.j2 +0 -0
  25. {docwright-0.1.2 → docwright-0.1.4}/docwright/cli.py +0 -0
  26. {docwright-0.1.2 → docwright-0.1.4}/docwright/outputs/__init__.py +0 -0
  27. {docwright-0.1.2 → docwright-0.1.4}/docwright/outputs/base.py +0 -0
  28. {docwright-0.1.2 → docwright-0.1.4}/docwright/outputs/direct.py +0 -0
  29. {docwright-0.1.2 → docwright-0.1.4}/docwright/outputs/factory.py +0 -0
  30. {docwright-0.1.2 → docwright-0.1.4}/docwright/outputs/pull_request.py +0 -0
  31. {docwright-0.1.2 → docwright-0.1.4}/docwright/providers/__init__.py +0 -0
  32. {docwright-0.1.2 → docwright-0.1.4}/docwright/providers/base.py +0 -0
  33. {docwright-0.1.2 → docwright-0.1.4}/docwright/providers/claude.py +0 -0
  34. {docwright-0.1.2 → docwright-0.1.4}/docwright/providers/factory.py +0 -0
  35. {docwright-0.1.2 → docwright-0.1.4}/docwright/providers/ollama.py +0 -0
  36. {docwright-0.1.2 → docwright-0.1.4}/docwright/providers/openai.py +0 -0
  37. {docwright-0.1.2 → docwright-0.1.4}/docwright/registry.py +0 -0
  38. {docwright-0.1.2 → docwright-0.1.4}/docwright/renderer.py +0 -0
  39. {docwright-0.1.2 → docwright-0.1.4}/docwright/reporters/__init__.py +0 -0
  40. {docwright-0.1.2 → docwright-0.1.4}/docwright/reporters/html.py +0 -0
  41. {docwright-0.1.2 → docwright-0.1.4}/docwright/reporters/terminal.py +0 -0
  42. {docwright-0.1.2 → docwright-0.1.4}/docwright/scaffolder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docwright
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: AI-powered documentation agent: auto-generates and maintains README & wiki on every commit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,20 @@
1
+ # {{ project_name }} Wiki
2
+
3
+ <!-- AUTO:overview -->
4
+ <!-- /AUTO:overview -->
5
+
6
+ ## Contents
7
+
8
+ <!-- AUTO:contents -->
9
+ <!-- /AUTO:contents -->
10
+
11
+ ## Quick Links
12
+
13
+ <!-- AUTO:quick_links -->
14
+ <!-- /AUTO:quick_links -->
15
+
16
+ <!-- MANUAL -->
17
+ ## Getting Help
18
+
19
+ Add team contacts and support channels here.
20
+ <!-- /MANUAL -->
@@ -51,6 +51,7 @@ class Config(BaseModel):
51
51
  triggers: TriggersConfig | None = None
52
52
  documents: list[DocumentConfig] = Field(default_factory=list)
53
53
  registry: RegistryConfig = Field(default_factory=RegistryConfig)
54
+ language: str = "en"
54
55
 
55
56
  @classmethod
56
57
  def load(cls, repo_root: Path) -> Config:
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from docwright.analyzer import DiffAnalyzer
11
+ from docwright.config import Config
12
+ from docwright.outputs.base import Output
13
+ from docwright.providers.base import LLMProvider
14
+ from docwright.registry import DocumentEntry, ProjectEntry, Registry
15
+ from docwright.renderer import DocumentRenderer, TemplateLoader
16
+
17
+ SYSTEM_PROMPT_TEMPLATE = (
18
+ "You are a technical documentation writer. You update specific sections of documentation "
19
+ "based on code changes. Return ONLY the updated section content — no markdown fences, "
20
+ "no explanations, no surrounding text. Write in clear, concise English. "
21
+ "Be accurate and specific to the actual code. "
22
+ "{language_instruction}"
23
+ )
24
+
25
+ _console = Console(stderr=False)
26
+
27
+
28
+ def build_system_prompt(language: str) -> str:
29
+ if language == "en":
30
+ language_instruction = "Generate all documentation in English language."
31
+ elif language == "ru":
32
+ language_instruction = "Generate all documentation in Russian language."
33
+ else:
34
+ language_instruction = f"Generate all documentation in {language} language."
35
+ return SYSTEM_PROMPT_TEMPLATE.format(language_instruction=language_instruction)
36
+
37
+
38
+ class DocsEngine:
39
+ def __init__(self, repo_root: Path, provider: LLMProvider, output: Output) -> None:
40
+ self.repo_root = repo_root
41
+ self.provider = provider
42
+ self.output = output
43
+ self.config = Config.load(repo_root)
44
+ self.renderer = DocumentRenderer()
45
+ self.loader = TemplateLoader(
46
+ source=self.config.templates.source,
47
+ local_path=(
48
+ repo_root / self.config.templates.local_path
49
+ if self.config.templates.source == "local"
50
+ else None
51
+ ),
52
+ )
53
+ self.system_prompt = build_system_prompt(self.config.language)
54
+
55
+ async def init(self) -> None:
56
+ total_start = time.monotonic()
57
+ changed_files: list[Path] = []
58
+ sections_count = 0
59
+
60
+ with Progress(
61
+ SpinnerColumn(),
62
+ TextColumn("[blue]{task.description}"),
63
+ console=_console,
64
+ transient=True,
65
+ ) as progress:
66
+ ctx_task = progress.add_task("Reading repository context...")
67
+ repo_context = self.gather_repo_context()
68
+ progress.remove_task(ctx_task)
69
+
70
+ _console.print("[green]✓[/green] Repository context ready")
71
+
72
+ for doc_config in self.config.documents:
73
+ target = self.repo_root / doc_config.target
74
+ template_text = self.loader.load(doc_config.template)
75
+ document = target.read_text() if target.exists() else template_text
76
+ section_names = self.renderer.auto_section_names(template_text)
77
+
78
+ for section_name in section_names:
79
+ with Progress(
80
+ SpinnerColumn(),
81
+ TextColumn("[blue]{task.description}"),
82
+ console=_console,
83
+ transient=True,
84
+ ) as progress:
85
+ task = progress.add_task(f"Generating {doc_config.target} → {section_name}...")
86
+ section_start = time.monotonic()
87
+ user_prompt = (
88
+ f"Repository context:\n{repo_context}\n\n"
89
+ f"Document type: {doc_config.type}\n"
90
+ f"Update the '{section_name}' section with accurate, detailed information."
91
+ )
92
+ updated_content = await self.provider.complete(
93
+ system=self.system_prompt, user=user_prompt
94
+ )
95
+ elapsed = time.monotonic() - section_start
96
+ document = self.renderer.patch_section(
97
+ document, section_name, updated_content + "\n"
98
+ )
99
+ progress.remove_task(task)
100
+
101
+ _console.print(
102
+ f"[green]✓[/green] {doc_config.target} → {section_name} ({elapsed:.1f}s)"
103
+ )
104
+ sections_count += 1
105
+
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ target.write_text(document)
108
+ changed_files.append(target)
109
+
110
+ if changed_files:
111
+ self.output.apply(changed_files, "docs: generate initial documentation")
112
+
113
+ Config.mark_initialized(self.repo_root)
114
+ self.register_in_registry()
115
+
116
+ total_elapsed = time.monotonic() - total_start
117
+ _console.print(
118
+ f"[green]✓[/green] Initialized {sections_count} documents in {total_elapsed:.1f}s"
119
+ )
120
+
121
+ async def run(self, diff_text: str) -> bool:
122
+ if not Config.is_initialized(self.repo_root):
123
+ await self.init()
124
+ return False
125
+
126
+ total_start = time.monotonic()
127
+ triggers = self.config.triggers
128
+ analyzer = DiffAnalyzer(
129
+ diff_text=diff_text,
130
+ trigger_paths=triggers.paths if triggers else [],
131
+ ignore_paths=triggers.ignore if triggers else [],
132
+ )
133
+
134
+ with Progress(
135
+ SpinnerColumn(),
136
+ TextColumn("[blue]{task.description}"),
137
+ console=_console,
138
+ transient=True,
139
+ ) as progress:
140
+ task = progress.add_task("Analyzing diff...")
141
+ has_changes = analyzer.has_relevant_changes()
142
+ progress.remove_task(task)
143
+
144
+ if not has_changes:
145
+ _console.print("[green]✓[/green] No relevant changes, skipping")
146
+ return True
147
+
148
+ changed_file_paths = analyzer.changed_files() if hasattr(analyzer, "changed_files") else []
149
+ _console.print(
150
+ f"[green]✓[/green] Found relevant changes in {len(changed_file_paths)} files"
151
+ )
152
+
153
+ changed_files: list[Path] = []
154
+ diff_summary = analyzer.diff_summary()
155
+ sections_count = 0
156
+
157
+ for doc_config in self.config.documents:
158
+ target = self.repo_root / doc_config.target
159
+ if not target.exists():
160
+ continue
161
+ document = target.read_text()
162
+ section_names = self.renderer.auto_section_names(document)
163
+ updated = False
164
+
165
+ for section_name in section_names:
166
+ with Progress(
167
+ SpinnerColumn(),
168
+ TextColumn("[blue]{task.description}"),
169
+ console=_console,
170
+ transient=True,
171
+ ) as progress:
172
+ task = progress.add_task(f"Generating {doc_config.target} → {section_name}...")
173
+ section_start = time.monotonic()
174
+ user_prompt = (
175
+ f"Diff summary:\n{diff_summary}\n\n"
176
+ f"Current document:\n{document}\n\n"
177
+ f"Update the '{section_name}' section if the diff affects it. "
178
+ f"If no update is needed, return the current section content unchanged."
179
+ )
180
+ updated_content = await self.provider.complete(
181
+ system=self.system_prompt, user=user_prompt
182
+ )
183
+ elapsed = time.monotonic() - section_start
184
+ new_document = self.renderer.patch_section(
185
+ document, section_name, updated_content + "\n"
186
+ )
187
+ progress.remove_task(task)
188
+
189
+ _console.print(
190
+ f"[green]✓[/green] {doc_config.target} → {section_name} ({elapsed:.1f}s)"
191
+ )
192
+ sections_count += 1
193
+
194
+ if new_document != document:
195
+ document = new_document
196
+ updated = True
197
+
198
+ if updated:
199
+ target.write_text(document)
200
+ changed_files.append(target)
201
+
202
+ if changed_files:
203
+ self.output.apply(changed_files, "docs: update documentation")
204
+
205
+ total_elapsed = time.monotonic() - total_start
206
+ _console.print(
207
+ f"[green]✓[/green] Updated {sections_count} sections in {total_elapsed:.1f}s"
208
+ )
209
+ return False
210
+
211
+ async def sync(self) -> None:
212
+ total_start = time.monotonic()
213
+ sections_count = 0
214
+
215
+ for doc_config in self.config.documents:
216
+ target = self.repo_root / doc_config.target
217
+ template_text = self.loader.load(doc_config.template)
218
+ document = target.read_text() if target.exists() else template_text
219
+ section_names = self.renderer.auto_section_names(template_text)
220
+ repo_context = self.gather_repo_context()
221
+
222
+ for section_name in section_names:
223
+ with Progress(
224
+ SpinnerColumn(),
225
+ TextColumn("[blue]{task.description}"),
226
+ console=_console,
227
+ transient=True,
228
+ ) as progress:
229
+ task = progress.add_task(f"Syncing {doc_config.target} → {section_name}...")
230
+ section_start = time.monotonic()
231
+ user_prompt = (
232
+ f"Repository context:\n{repo_context}\n\n"
233
+ f"Current document:\n{document}\n\n"
234
+ f"Re-sync the '{section_name}' section to be accurate and up to date."
235
+ )
236
+ updated_content = await self.provider.complete(
237
+ system=self.system_prompt, user=user_prompt
238
+ )
239
+ elapsed = time.monotonic() - section_start
240
+ document = self.renderer.patch_section(
241
+ document, section_name, updated_content + "\n"
242
+ )
243
+ progress.remove_task(task)
244
+
245
+ _console.print(
246
+ f"[green]✓[/green] {doc_config.target} → {section_name} ({elapsed:.1f}s)"
247
+ )
248
+ sections_count += 1
249
+
250
+ target.parent.mkdir(parents=True, exist_ok=True)
251
+ target.write_text(document)
252
+
253
+ total_elapsed = time.monotonic() - total_start
254
+ _console.print(f"[green]✓[/green] Synced {sections_count} sections in {total_elapsed:.1f}s")
255
+
256
+ def gather_repo_context(self) -> str:
257
+ lines: list[str] = [f"Repo root: {self.repo_root.name}"]
258
+ for candidate in ["pyproject.toml", "package.json", "composer.json", "go.mod"]:
259
+ path = self.repo_root / candidate
260
+ if path.exists():
261
+ lines.append(f"\n{candidate}:\n{path.read_text()[:2000]}")
262
+ break
263
+ src_dirs = ["app", "src", "lib"]
264
+ for src_dir in src_dirs:
265
+ full = self.repo_root / src_dir
266
+ if full.exists():
267
+ files = [str(p.relative_to(self.repo_root)) for p in full.rglob("*.py")][:20]
268
+ lines.append(f"\nSource files in {src_dir}/: {', '.join(files)}")
269
+ break
270
+ return "\n".join(lines)
271
+
272
+ def register_in_registry(self) -> None:
273
+ registry_path = self.repo_root / self.config.registry.path
274
+ registry = Registry(registry_path)
275
+ try:
276
+ remote = (
277
+ subprocess.check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_root)
278
+ .decode()
279
+ .strip()
280
+ )
281
+ except subprocess.CalledProcessError:
282
+ remote = ""
283
+ registry.register(
284
+ ProjectEntry(
285
+ name=self.repo_root.name,
286
+ path=str(self.repo_root),
287
+ remote=remote,
288
+ documents=[DocumentEntry(target=d.target) for d in self.config.documents],
289
+ )
290
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "docwright"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "AI-powered documentation agent: auto-generates and maintains README & wiki on every commit"
5
5
  authors = ["Artem Gotlib <gotlib.artem.m@gmail.com>"]
6
6
  license = "MIT"
@@ -1,165 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import subprocess
4
- from pathlib import Path
5
-
6
- from docwright.analyzer import DiffAnalyzer
7
- from docwright.config import Config
8
- from docwright.outputs.base import Output
9
- from docwright.providers.base import LLMProvider
10
- from docwright.registry import DocumentEntry, ProjectEntry, Registry
11
- from docwright.renderer import DocumentRenderer, TemplateLoader
12
-
13
- SYSTEM_PROMPT = (
14
- "You are a technical documentation writer. You update specific sections of documentation "
15
- "based on code changes. Return ONLY the updated section content — no markdown fences, "
16
- "no explanations, no surrounding text. Write in clear, concise English. "
17
- "Be accurate and specific to the actual code."
18
- )
19
-
20
-
21
- class DocsEngine:
22
- def __init__(self, repo_root: Path, provider: LLMProvider, output: Output) -> None:
23
- self.repo_root = repo_root
24
- self.provider = provider
25
- self.output = output
26
- self.config = Config.load(repo_root)
27
- self.renderer = DocumentRenderer()
28
- self.loader = TemplateLoader(
29
- source=self.config.templates.source,
30
- local_path=(
31
- repo_root / self.config.templates.local_path
32
- if self.config.templates.source == "local"
33
- else None
34
- ),
35
- )
36
-
37
- async def init(self) -> None:
38
- changed_files: list[Path] = []
39
- for doc_config in self.config.documents:
40
- target = self.repo_root / doc_config.target
41
- template_text = self.loader.load(doc_config.template)
42
- document = target.read_text() if target.exists() else template_text
43
- section_names = self.renderer.auto_section_names(template_text)
44
- repo_context = self.gather_repo_context()
45
- for section_name in section_names:
46
- user_prompt = (
47
- f"Repository context:\n{repo_context}\n\n"
48
- f"Document type: {doc_config.type}\n"
49
- f"Update the '{section_name}' section with accurate, detailed information."
50
- )
51
- updated_content = await self.provider.complete(
52
- system=SYSTEM_PROMPT, user=user_prompt
53
- )
54
- document = self.renderer.patch_section(
55
- document, section_name, updated_content + "\n"
56
- )
57
- target.parent.mkdir(parents=True, exist_ok=True)
58
- target.write_text(document)
59
- changed_files.append(target)
60
- if changed_files:
61
- self.output.apply(changed_files, "docs: generate initial documentation")
62
- Config.mark_initialized(self.repo_root)
63
- self.register_in_registry()
64
-
65
- async def run(self, diff_text: str) -> bool:
66
- if not Config.is_initialized(self.repo_root):
67
- await self.init()
68
- return False
69
- triggers = self.config.triggers
70
- analyzer = DiffAnalyzer(
71
- diff_text=diff_text,
72
- trigger_paths=triggers.paths if triggers else [],
73
- ignore_paths=triggers.ignore if triggers else [],
74
- )
75
- if not analyzer.has_relevant_changes():
76
- return True
77
- changed_files: list[Path] = []
78
- diff_summary = analyzer.diff_summary()
79
- for doc_config in self.config.documents:
80
- target = self.repo_root / doc_config.target
81
- if not target.exists():
82
- continue
83
- document = target.read_text()
84
- section_names = self.renderer.auto_section_names(document)
85
- updated = False
86
- for section_name in section_names:
87
- user_prompt = (
88
- f"Diff summary:\n{diff_summary}\n\n"
89
- f"Current document:\n{document}\n\n"
90
- f"Update the '{section_name}' section if the diff affects it. "
91
- f"If no update is needed, return the current section content unchanged."
92
- )
93
- updated_content = await self.provider.complete(
94
- system=SYSTEM_PROMPT, user=user_prompt
95
- )
96
- new_document = self.renderer.patch_section(
97
- document, section_name, updated_content + "\n"
98
- )
99
- if new_document != document:
100
- document = new_document
101
- updated = True
102
- if updated:
103
- target.write_text(document)
104
- changed_files.append(target)
105
- if changed_files:
106
- self.output.apply(changed_files, "docs: update documentation")
107
- return False
108
-
109
- async def sync(self) -> None:
110
- for doc_config in self.config.documents:
111
- target = self.repo_root / doc_config.target
112
- template_text = self.loader.load(doc_config.template)
113
- document = target.read_text() if target.exists() else template_text
114
- section_names = self.renderer.auto_section_names(template_text)
115
- repo_context = self.gather_repo_context()
116
- for section_name in section_names:
117
- user_prompt = (
118
- f"Repository context:\n{repo_context}\n\n"
119
- f"Current document:\n{document}\n\n"
120
- f"Re-sync the '{section_name}' section to be accurate and up to date."
121
- )
122
- updated_content = await self.provider.complete(
123
- system=SYSTEM_PROMPT, user=user_prompt
124
- )
125
- document = self.renderer.patch_section(
126
- document, section_name, updated_content + "\n"
127
- )
128
- target.parent.mkdir(parents=True, exist_ok=True)
129
- target.write_text(document)
130
-
131
- def gather_repo_context(self) -> str:
132
- lines: list[str] = [f"Repo root: {self.repo_root.name}"]
133
- for candidate in ["pyproject.toml", "package.json", "composer.json", "go.mod"]:
134
- path = self.repo_root / candidate
135
- if path.exists():
136
- lines.append(f"\n{candidate}:\n{path.read_text()[:2000]}")
137
- break
138
- src_dirs = ["app", "src", "lib"]
139
- for src_dir in src_dirs:
140
- full = self.repo_root / src_dir
141
- if full.exists():
142
- files = [str(p.relative_to(self.repo_root)) for p in full.rglob("*.py")][:20]
143
- lines.append(f"\nSource files in {src_dir}/: {', '.join(files)}")
144
- break
145
- return "\n".join(lines)
146
-
147
- def register_in_registry(self) -> None:
148
- registry_path = self.repo_root / self.config.registry.path
149
- registry = Registry(registry_path)
150
- try:
151
- remote = (
152
- subprocess.check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_root)
153
- .decode()
154
- .strip()
155
- )
156
- except subprocess.CalledProcessError:
157
- remote = ""
158
- registry.register(
159
- ProjectEntry(
160
- name=self.repo_root.name,
161
- path=str(self.repo_root),
162
- remote=remote,
163
- documents=[DocumentEntry(target=d.target) for d in self.config.documents],
164
- )
165
- )
File without changes
File without changes
File without changes