gutarik 0.1.0__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.
gutarik-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: gutarik
3
+ Version: 0.1.0
4
+ Summary: Auto .md docs generator
5
+ License: MIT
6
+ Keywords: Backend,Documentation,Docs,Docstring,Generator,Python,Markdown
7
+ Author: ProudRykar
8
+ Author-email: proudrykar@mail.ru
9
+ Requires-Python: >=3.10
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Provides-Extra: yaml-toml
18
+ Requires-Dist: pyyaml (>=6.0.2) ; extra == "yaml-toml"
19
+ Requires-Dist: toml (>=0.10.2,<0.11.0) ; extra == "yaml-toml"
20
+ Project-URL: Changelog, https://github.com/ProudRykar/GUTARIK/releases
21
+ Project-URL: Issues, https://github.com/ProudRykar/GUTARIK/issues
22
+ Project-URL: Repository, https://github.com/ProudRykar/GUTARIK
23
+ Description-Content-Type: text/markdown
24
+
25
+ # GUTARIK
26
+
@@ -0,0 +1 @@
1
+ # GUTARIK
File without changes
@@ -0,0 +1,249 @@
1
+ """Модуль для чтения конфигурационных файлов."""
2
+
3
+ # Config loader
4
+ import importlib
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Union, List
8
+
9
+ GENERIC_POINTER = "gutarik"
10
+
11
+
12
+ def __yaml_config_parser__(path: str, pointer: str = GENERIC_POINTER) -> Dict[str, Any]:
13
+ """Приватный метод для парсинга YML/YAML конфигурации.
14
+
15
+ Args:
16
+ path (str): Путь к файлу gutarik.yml или gutarik.yaml.
17
+ pointer (str): Указатель на раздел конфигурации для чтения.
18
+
19
+ Raises:
20
+ ImportError: Если PyYAML не установлен.
21
+ FileNotFoundError: Если файл не найден.
22
+ ValueError: Если YAML некорректен или имя файла не начинается с 'gutarik'.
23
+
24
+ Returns:
25
+ Dict[str, Any]: Словарь с параметрами конфигурации.
26
+ """
27
+ try:
28
+ import yaml # type: ignore
29
+ except ImportError:
30
+ raise ImportError(
31
+ "To parse YAML/YML configuration files, you need to install PyYAML: "
32
+ "`pip install pyyaml` or `poetry add pyyaml`"
33
+ )
34
+
35
+ path_obj = Path(path)
36
+ if path_obj.name.split(".")[0] != "gutarik":
37
+ raise ValueError(f"Config file must start with 'gutarik', got: {path_obj.name}")
38
+
39
+ try:
40
+ with open(path_obj, "r", encoding="utf-8") as file:
41
+ config = yaml.safe_load(file)
42
+ return config.get(pointer, {}) if config else {}
43
+ except FileNotFoundError:
44
+ raise FileNotFoundError(f"YAML config file not found at: {path}")
45
+ except yaml.YAMLError as e:
46
+ raise ValueError(f"Error parsing YAML file: {e}")
47
+
48
+
49
+ def __toml_config_parser__(path: str, pointer: str = GENERIC_POINTER) -> Dict[str, Any]:
50
+ """Приватный метод для парсинга TOML конфигурации.
51
+
52
+ Args:
53
+ path (str): Путь к файлу gutarik.toml или pyproject.toml.
54
+ pointer (str): Указатель на раздел конфигурации для чтения.
55
+
56
+ Raises:
57
+ ImportError: Если toml не установлен (для Python < 3.11).
58
+ FileNotFoundError: Если файл не найден.
59
+ ValueError: Если TOML некорректен или имя файла не начинается с 'gutarik'
60
+ (кроме pyproject.toml).
61
+
62
+ Returns:
63
+ Dict[str, Any]: Словарь с параметрами конфигурации.
64
+ """
65
+ if sys.version_info >= (3, 11):
66
+ import tomllib as toml
67
+ else:
68
+ try:
69
+ import toml
70
+ except ImportError:
71
+ raise ImportError(
72
+ "To parse TOML configuration files, you need to install toml: "
73
+ "`pip install toml` or `poetry add toml`"
74
+ )
75
+
76
+ path_obj = Path(path)
77
+ if path_obj.name != "pyproject.toml" and path_obj.name.split(".")[0] != "gutarik":
78
+ raise ValueError(
79
+ f"Config file must start with 'gutarik' or be 'pyproject.toml', got: {path_obj.name}"
80
+ )
81
+
82
+ try:
83
+ with open(path_obj, "rb") as file:
84
+ config = toml.load(file)
85
+ if path_obj.name == "pyproject.toml":
86
+ return config.get("tool", {}).get(pointer, {})
87
+ return config.get(pointer, {}) if config else {}
88
+ except FileNotFoundError:
89
+ raise FileNotFoundError(f"TOML config file not found at: {path}")
90
+ except toml.TOMLDecodeError as e:
91
+ raise ValueError(f"Error parsing TOML file: {e}")
92
+
93
+
94
+ def __python_config_parser__(
95
+ path: str, pointer: str = GENERIC_POINTER
96
+ ) -> Dict[str, Any]:
97
+ """Приватный метод для парсинга конфигурации на Python.
98
+
99
+ Args:
100
+ path (str): Путь к файлу gutarik.py.
101
+ pointer (str): Указатель на раздел конфигурации (игнорируется для Python файлов).
102
+
103
+ Raises:
104
+ FileNotFoundError: Если файл не найден.
105
+ ValueError: Если имя файла не начинается с 'gutarik' или модуль не может быть загружен.
106
+
107
+ Returns:
108
+ Dict[str, Any]: Словарь с параметрами конфигурации.
109
+ """
110
+ path_obj = Path(path)
111
+ if path_obj.name.split(".")[0] != "gutarik":
112
+ raise ValueError(f"Config file must start with 'gutarik', got: {path_obj.name}")
113
+
114
+ try:
115
+ module_name = path_obj.stem
116
+ spec = importlib.util.spec_from_file_location(module_name, path_obj) # type: ignore
117
+ if spec is None or spec.loader is None:
118
+ raise ValueError(f"Failed to load module from {path_obj}")
119
+
120
+ module = importlib.util.module_from_spec(spec) # type: ignore
121
+ sys.modules[module_name] = module
122
+ spec.loader.exec_module(module)
123
+
124
+ config = {
125
+ key: value
126
+ for key, value in module.__dict__.items()
127
+ if not key.startswith("_") and not callable(value)
128
+ }
129
+ return config.get(pointer, {}) if pointer in config else config
130
+ except FileNotFoundError:
131
+ raise FileNotFoundError(f"Python config file not found at: {path}")
132
+ except Exception as e:
133
+ raise ValueError(f"Error loading Python config: {e}")
134
+
135
+
136
+ def __config_maker__(
137
+ config: Union[str, Dict[str, Any]], pointer: str = GENERIC_POINTER
138
+ ) -> Dict[str, Any]:
139
+ """Базовый парсер конфигурации.
140
+
141
+ Args:
142
+ config (Union[str, Dict[str, Any]]): Словарь конфигурации или путь к файлу gutarik.* или pyproject.toml.
143
+ pointer (str): Указатель на раздел конфигурации для чтения.
144
+
145
+ Raises:
146
+ ValueError: Если формат файла не поддерживается или имя файла не начинается с 'gutarik'
147
+ (кроме pyproject.toml).
148
+
149
+ Returns:
150
+ Dict[str, Any]: Распарсенная конфигурация.
151
+ """
152
+ if isinstance(config, dict):
153
+ return config.get(pointer, {}) if pointer else config
154
+
155
+ if isinstance(config, str):
156
+ path_obj = Path(config)
157
+ ext = path_obj.suffix.lower()
158
+ if ext in (".yml", ".yaml"):
159
+ return __yaml_config_parser__(config, pointer)
160
+ elif ext == ".toml":
161
+ return __toml_config_parser__(config, pointer)
162
+ elif ext == ".py":
163
+ return __python_config_parser__(config, pointer)
164
+ else:
165
+ raise ValueError("Only YML/YAML, TOML, or Python configs are supported!")
166
+
167
+ raise TypeError("Config must be a string (file path) or dictionary")
168
+
169
+
170
+ def load_config(
171
+ config: Union[str, Dict[str, Any], None] = None, pointer: str = GENERIC_POINTER
172
+ ) -> Dict[str, Any]:
173
+ """Загружает конфигурацию из файла, имя которого начинается с 'gutarik' (.yml, .yaml, .toml, .py) или из pyproject.toml.
174
+
175
+ Args:
176
+ config (Union[str, Dict[str, Any], None]): Словарь конфигурации или путь к файлу конфигурации.
177
+ Если None, выполняется поиск pyproject.toml или gutarik.* в текущей директории.
178
+ pointer (str): Указатель на раздел конфигурации для чтения.
179
+
180
+ Returns:
181
+ Dict[str, Any]: Словарь с параметрами конфигурации.
182
+
183
+ Raises:
184
+ ValueError: Если конфигурационный файл не найден, найдено несколько файлов или формат не поддерживается.
185
+ """
186
+ supported_extensions = {".yml", ".yaml", ".toml", ".py"}
187
+
188
+ if isinstance(config, dict):
189
+ return __config_maker__(config, pointer)
190
+
191
+ if config:
192
+ config_path = Path(config)
193
+ if not config_path.exists():
194
+ raise ValueError(f"Config file {config_path} does not exist")
195
+ return __config_maker__(str(config_path), pointer)
196
+
197
+ pyproject_path = Path.cwd() / "pyproject.toml"
198
+ if pyproject_path.exists():
199
+ try:
200
+ config = __toml_config_parser__(str(pyproject_path), pointer)
201
+ if config:
202
+ return config
203
+ except ValueError:
204
+ pass
205
+
206
+ config_files: List[Path] = []
207
+ for ext in supported_extensions:
208
+ config_files.extend(Path.cwd().glob(f"gutarik{ext}"))
209
+
210
+ if not config_files:
211
+ print(
212
+ "No config file with name 'gutarik.*' or '[tool.gutarik]' in pyproject.toml found, using default configuration"
213
+ )
214
+ return {
215
+ "PROJECT_DIRS": [Path("gutarik/src")],
216
+ "DOCS_DIR": Path("docs"),
217
+ "SUPPORTED_EXT": [".py"],
218
+ "WIKI_REPO": "https://github.com/user/repo.wiki.git",
219
+ "LOCAL_WIKI_DIR": Path("wiki_tmp"),
220
+ "EXCLUDE_DIRS": [],
221
+ }
222
+ if len(config_files) > 1:
223
+ raise ValueError(
224
+ f"Multiple config files found: {config_files}. Specify one file."
225
+ )
226
+
227
+ return __config_maker__(str(config_files[0]), pointer)
228
+
229
+
230
+ def validate_config(config: Dict[str, Any]) -> None:
231
+ """Проверяет наличие обязательных ключей конфигурации.
232
+
233
+ Args:
234
+ config (Dict[str, Any]): Словарь конфигурации.
235
+
236
+ Raises:
237
+ ValueError: Если обязательные ключи отсутствуют.
238
+ """
239
+ required_keys = {
240
+ "project_dirs",
241
+ "docs_dir",
242
+ "supported_ext",
243
+ "wiki_repo",
244
+ "local_wiki_dir",
245
+ "exclude_dirs",
246
+ }
247
+ missing_keys = required_keys - set(config.keys())
248
+ if missing_keys:
249
+ raise ValueError(f"Missing required configuration keys: {missing_keys}")
@@ -0,0 +1,444 @@
1
+ """Модуль для генерации Markdown-документации."""
2
+ # GenerateDocs
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+ from typing import Any
7
+ import ast
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+
13
+ from gutarik.config_loader import load_config, validate_config
14
+
15
+ config = load_config(os.getenv("gutarik_config"), pointer="gutarik")
16
+ validate_config(config)
17
+
18
+ PROJECT_DIRS = [Path(p) for p in config["project_dirs"]]
19
+ DOCS_DIR = Path(config["docs_dir"])
20
+ SUPPORTED_EXT = config["supported_ext"]
21
+ WIKI_REPO = config["wiki_repo"]
22
+ LOCAL_WIKI_DIR = Path(config["local_wiki_dir"])
23
+ EXCLUDE_DIRS = [Path(p) for p in config["exclude_dirs"]]
24
+
25
+
26
+ def parse_google_docstring(docstring: str) -> dict[str, Any]:
27
+ """Парсит Google-style докстринг в структуру словаря.
28
+
29
+ Args:
30
+ docstring (str): Исходный докстринг функции, метода или класса.
31
+
32
+ Returns:
33
+ dict: Словарь с ключами:
34
+ - first_line (str): Первая строка описания.
35
+ - rest_description (str): Основное описание.
36
+ - args (str): Раздел аргументов.
37
+ - returns (str): Раздел возвращаемых значений.
38
+ - raises (str): Раздел исключений.
39
+ """
40
+ if not docstring:
41
+ return {
42
+ "first_line": "",
43
+ "rest_description": "",
44
+ "args": "",
45
+ "returns": "",
46
+ "raises": "",
47
+ }
48
+
49
+ sections: dict[str, Any] = {
50
+ "first_line": "",
51
+ "rest_description": [],
52
+ "args": [],
53
+ "returns": [],
54
+ "raises": [],
55
+ }
56
+ current_section = "description"
57
+ is_first_line = True
58
+
59
+ for line in docstring.splitlines():
60
+ line_strip = line.strip()
61
+ if re.match(r"^(Args|Attributes):", line_strip):
62
+ current_section = "args"
63
+ elif re.match(r"^Returns:", line_strip):
64
+ current_section = "returns"
65
+ elif re.match(r"^(Raises|Exceptions):", line_strip):
66
+ current_section = "raises"
67
+ else:
68
+ if current_section == "description":
69
+ if is_first_line and line_strip:
70
+ sections["first_line"] = line_strip
71
+ is_first_line = False
72
+ else:
73
+ if isinstance(sections["rest_description"], list):
74
+ sections["rest_description"].append(line)
75
+ else:
76
+ if isinstance(sections[current_section], list):
77
+ sections[current_section].append(line)
78
+
79
+ for key in sections:
80
+ if key == "first_line":
81
+ continue
82
+ sections[key] = "\n".join(sections[key]).strip()
83
+ return sections
84
+
85
+
86
+ def extract_docstrings(file_path: Path) -> dict[Any, Any]:
87
+ """Извлекает докстринги и тела функций/методов из Python-файла.
88
+
89
+ Args:
90
+ file_path (Path): Путь к Python-файлу.
91
+
92
+ Returns:
93
+ dict: Структура с докстрингами и кодом:
94
+ - module (str): Докстринг модуля.
95
+ - classes (dict): Классы и их методы.
96
+ - functions (dict): Глобальные функции.
97
+ """
98
+ with open(file_path, encoding="utf-8") as f:
99
+ tree = ast.parse(f.read(), filename=str(file_path))
100
+
101
+ docstrings: dict[str, Any] = {
102
+ "module": ast.get_docstring(tree),
103
+ "classes": {},
104
+ }
105
+
106
+ for node in tree.body:
107
+ if isinstance(node, ast.ClassDef):
108
+ class_doc: dict[str, Any] = parse_google_docstring(
109
+ ast.get_docstring(node) or ""
110
+ )
111
+ class_doc["methods"] = {}
112
+ for cnode in node.body:
113
+ if isinstance(cnode, (ast.FunctionDef, ast.AsyncFunctionDef)):
114
+ method_doc = parse_google_docstring(ast.get_docstring(cnode) or "")
115
+ method_doc["body"] = get_function_body(file_path, cnode)
116
+ class_doc["methods"][cnode.name] = method_doc
117
+ docstrings["classes"][node.name] = class_doc
118
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
119
+ func_doc = parse_google_docstring(ast.get_docstring(node) or "")
120
+ func_doc["body"] = get_function_body(file_path, node)
121
+ docstrings.setdefault("functions", {})[node.name] = func_doc
122
+
123
+ return docstrings
124
+
125
+
126
+ def get_function_body(
127
+ file_path: Path, node: ast.FunctionDef | ast.AsyncFunctionDef
128
+ ) -> str:
129
+ """Извлекает полный исходный код функции или метода, включая сигнатуру и декораторы.
130
+
131
+ Args:
132
+ file_path (Path): Путь к файлу с исходным кодом.
133
+ node (ast.AST): AST-узел функции или метода.
134
+
135
+ Returns:
136
+ str: Текст исходного кода функции.
137
+ """
138
+ with open(file_path, encoding="utf-8") as f:
139
+ lines = f.readlines()
140
+
141
+ start_line = node.lineno - 1
142
+ if hasattr(node, "decorator_list") and node.decorator_list:
143
+ decorator_start = min(decorator.lineno - 1 for decorator in node.decorator_list)
144
+ start_line = decorator_start
145
+ while start_line > 0 and lines[start_line - 1].strip().startswith("@"):
146
+ start_line -= 1
147
+
148
+ end_line: int = node.end_lineno or start_line + 1
149
+
150
+ while end_line < len(lines):
151
+ line = lines[end_line].strip()
152
+ if line and line.startswith("@"):
153
+ break
154
+ if line and not line.startswith(" "):
155
+ break
156
+ end_line += 1
157
+
158
+ body = "".join(lines[start_line:end_line]).rstrip()
159
+ return body
160
+
161
+
162
+ def format_function_md(name: str, doc: dict[str, Any], is_method: bool = False) -> str:
163
+ """Форматирует функцию или метод в Markdown с таблицами аргументов, возвращаемых значений и исключений.
164
+
165
+ Args:
166
+ name (str): Имя функции или метода.
167
+ doc (dict): Докстринг функции, разобранный через parse_google_docstring.
168
+ is_method (bool): Флаг, указывающий, что это метод класса.
169
+
170
+ Returns:
171
+ str: Сформатированный Markdown.
172
+ """
173
+ display_name = name.replace("__init__", "init")
174
+ is_async = doc["body"].strip().startswith("async def")
175
+ prefix = "async def" if is_async else "def"
176
+ md = [f"## {prefix} {display_name}:"]
177
+
178
+ if doc["first_line"]:
179
+ md.append(f"#### {doc['first_line']}")
180
+
181
+ if doc["rest_description"]:
182
+ md.append(doc["rest_description"])
183
+
184
+ # HTTP route
185
+ if "@" in doc["body"]:
186
+ route_lines: list[str] = []
187
+ current_route: list[str] = []
188
+ for line in doc["body"].splitlines():
189
+ line_strip = line.strip()
190
+ if line_strip.startswith(("@get", "@post", "@put", "@delete")) or (
191
+ current_route and line_strip
192
+ ):
193
+ current_route.append(line_strip)
194
+ if line_strip.endswith(")"):
195
+ route_lines.append(" ".join(current_route))
196
+ current_route = []
197
+ if route_lines:
198
+ md.append("#### Маршруты:")
199
+ for route in route_lines:
200
+ md.append(f"- `{route}`")
201
+
202
+ # Аргументы
203
+ if doc["args"]:
204
+ md.append("\n#### Аргументы")
205
+ md.append("| Аргумент | Тип | Описание |")
206
+ md.append("|----------|-----|----------|")
207
+ for arg in doc["args"].split("\n"):
208
+ if arg.strip():
209
+ parts = arg.strip().split(":", 1)
210
+ if len(parts) == 2:
211
+ name_type = parts[0].strip()
212
+ desc = parts[1].strip()
213
+ if "(" in name_type and ")" in name_type:
214
+ arg_name = name_type.split("(")[0].strip()
215
+ arg_type = name_type.split("(")[1].replace(")", "").strip()
216
+ else:
217
+ arg_name = name_type
218
+ arg_type = ""
219
+ md.append(f"| `{arg_name}` | `{arg_type}` | {desc} |")
220
+ else:
221
+ md.append(f"| `{arg.strip()}` | | |")
222
+
223
+ # Возвращаемое значение
224
+ if doc["returns"] and doc["returns"].strip().lower() != "none":
225
+ md.append("\n#### Возвращает")
226
+ md.append("| Тип | Описание |")
227
+ md.append("|-----|----------|")
228
+ for ret in doc["returns"].split("\n"):
229
+ if ret.strip():
230
+ parts = ret.strip().split(":", 1)
231
+ if len(parts) == 2:
232
+ ret_type = parts[0].strip()
233
+ ret_desc = parts[1].strip()
234
+ md.append(f"| `{ret_type}` | {ret_desc} |")
235
+ else:
236
+ md.append(f"| `{ret.strip()}` | |")
237
+
238
+ # Исключения
239
+ if doc["raises"]:
240
+ md.append("\n#### Исключения")
241
+ md.append("| Исключение | Описание |")
242
+ md.append("|------------|----------|")
243
+ for exc in doc["raises"].split("\n"):
244
+ if exc.strip():
245
+ parts = exc.strip().split(":", 1)
246
+ if len(parts) == 2:
247
+ exc_type = parts[0].strip()
248
+ exc_desc = parts[1].strip()
249
+ md.append(f"| `{exc_type}` | {exc_desc} |")
250
+ else:
251
+ md.append(f"| `{exc.strip()}` | |")
252
+
253
+ md.append("\n```python")
254
+ md.append(doc["body"])
255
+ md.append("```")
256
+
257
+ return "\n".join(md)
258
+
259
+
260
+ def write_md(file_path: Path, docstrings: dict[str, Any]) -> str:
261
+ """Генерирует Markdown-файл для модуля или класса.
262
+
263
+ Args:
264
+ file_path (Path): Путь к Python-файлу.
265
+ docstrings (dict): Словарь с докстрингами, полученный через extract_docstrings.
266
+
267
+ Returns:
268
+ str: Полный Markdown контент для файла.
269
+ """
270
+ md_content = []
271
+
272
+ if docstrings.get("module"):
273
+ md_content.append(f"# Модуль {file_path.stem}\n\n{docstrings['module']}\n")
274
+
275
+ for cls_name, cls_doc in docstrings.get("classes", {}).items():
276
+ md_content.append(f"## Класс {cls_name}\n")
277
+ if cls_doc.get("first_line"):
278
+ md_content.append(f"**{cls_doc['first_line']}**")
279
+ if cls_doc.get("rest_description"):
280
+ md_content.append(cls_doc["rest_description"])
281
+ if cls_doc.get("args"):
282
+ md_content.append("\n**Args:**")
283
+ for arg in cls_doc["args"].split("\n"):
284
+ if arg.strip():
285
+ parts = arg.strip().split(":", 1)
286
+ if len(parts) == 2:
287
+ arg_name_type = parts[0].strip()
288
+ arg_desc = parts[1].strip()
289
+ md_content.append(f"- `{arg_name_type}`: {arg_desc}")
290
+ else:
291
+ md_content.append(f"- `{arg.strip()}`")
292
+ if cls_doc.get("methods"):
293
+ md_content.append("\n---")
294
+ for method_name, method_doc in cls_doc.get("methods", {}).items():
295
+ md_content.append(
296
+ format_function_md(method_name, method_doc, is_method=True)
297
+ )
298
+ md_content.append("---")
299
+
300
+ for func_name, func_doc in docstrings.get("functions", {}).items():
301
+ md_content.append(format_function_md(func_name, func_doc))
302
+ md_content.append("---")
303
+
304
+ return "\n".join(md_content)
305
+
306
+
307
+ def create_docs(src_dirs: list[Path], dst_dir: Path, exclude_dirs: list[Path]) -> None:
308
+ """Создает Markdown-документацию для всех Python-файлов из списка директорий.
309
+
310
+ Args:
311
+ src_dirs (list[Path]): Список исходных директорий.
312
+ dst_dir (Path): Папка, куда будут сохранены сгенерированные Markdown-файлы.
313
+ """
314
+ for src_dir in src_dirs:
315
+ for root, dirs, files in os.walk(src_dir):
316
+ # Пропускаем исключённые директории
317
+ dirs[:] = [d for d in dirs if Path(root) / d not in exclude_dirs]
318
+ rel_path = Path(root).relative_to(src_dir)
319
+ target_dir = dst_dir / rel_path
320
+ target_dir.mkdir(parents=True, exist_ok=True)
321
+
322
+ for file in files:
323
+ file_path = Path(root) / file
324
+ if (
325
+ file_path.suffix in SUPPORTED_EXT
326
+ and file_path.name != "__init__.py"
327
+ ):
328
+ docstrings = extract_docstrings(file_path)
329
+ md_content = write_md(file_path, docstrings)
330
+ md_file = target_dir / f"{file_path.stem}.md"
331
+ with open(md_file, "w", encoding="utf-8") as f:
332
+ f.write(md_content)
333
+
334
+
335
+ def rename_wiki_files_by_header(local_wiki_dir: Path, docs_dir: Path) -> None:
336
+ """Переименовывает .md файлы в .wiki_tmp на основании второй строки исходных .py файлов.
337
+
338
+ Если вторая строка файла начинается с # , используется её содержимое (без решётки и пробелов) как новое имя Markdown-файла.
339
+ Если такого заголовка нет, имя остаётся прежним.
340
+
341
+ Args:
342
+ local_wiki_dir (Path): Локальная директория Wiki (.wiki_tmp)
343
+ docs_dir (Path): Папка с документацией (docs/)
344
+ """
345
+ for root, _, files in os.walk(local_wiki_dir):
346
+ for file in files:
347
+ if not file.endswith(".md"):
348
+ continue
349
+
350
+ md_path = Path(root) / file
351
+ rel_path = md_path.relative_to(local_wiki_dir)
352
+ py_source = docs_dir / rel_path
353
+ py_source = py_source.with_suffix(".py")
354
+
355
+ if not py_source.exists():
356
+ for src_dir in PROJECT_DIRS:
357
+ possible_py = src_dir / rel_path
358
+ possible_py = possible_py.with_suffix(".py")
359
+ if possible_py.exists():
360
+ py_source = possible_py
361
+ break
362
+
363
+ if not py_source.exists():
364
+ continue
365
+
366
+ try:
367
+ with open(py_source, encoding="utf-8") as f:
368
+ lines = f.readlines()
369
+ if len(lines) < 2:
370
+ continue
371
+ second_line = lines[1].strip()
372
+ if second_line.startswith("# "):
373
+ new_name = second_line[2:].strip()
374
+ if not new_name:
375
+ continue
376
+ new_md_name = f"{new_name}.md"
377
+ new_md_path = md_path.with_name(new_md_name)
378
+ if new_md_path != md_path:
379
+ os.rename(md_path, new_md_path)
380
+ print(f"[Wiki] Переименован: {md_path.name} → {new_md_name}")
381
+ except Exception as e:
382
+ print(f"[Wiki] Ошибка при обработке {md_path}: {e}")
383
+
384
+
385
+ def push_to_wiki(docs_dir: Path) -> None:
386
+ """Копирует Markdown-документы в локальный клон Wiki и пушит изменения в удаленный репозиторий.
387
+
388
+ Args:
389
+ docs_dir (Path): Папка с сгенерированной документацией.
390
+ """
391
+ if not LOCAL_WIKI_DIR.exists():
392
+ subprocess.run(["git", "clone", WIKI_REPO, str(LOCAL_WIKI_DIR)], check=True)
393
+
394
+ for root, _, files in os.walk(docs_dir):
395
+ rel_path = Path(root).relative_to(docs_dir)
396
+ target_dir = LOCAL_WIKI_DIR / rel_path
397
+ target_dir.mkdir(parents=True, exist_ok=True)
398
+ for file in files:
399
+ if file.endswith(".md"):
400
+ src_file = Path(root) / file
401
+ dst_file = target_dir / file
402
+ shutil.copy2(src_file, dst_file)
403
+
404
+ rename_wiki_files_by_header(LOCAL_WIKI_DIR, docs_dir)
405
+
406
+ subprocess.run(["git", "-C", str(LOCAL_WIKI_DIR), "add", "."], check=True)
407
+ status = subprocess.run(
408
+ ["git", "-C", str(LOCAL_WIKI_DIR), "status", "--porcelain"],
409
+ capture_output=True,
410
+ text=True,
411
+ check=True,
412
+ )
413
+ if status.stdout.strip():
414
+ subprocess.run(
415
+ [
416
+ "git",
417
+ "-C",
418
+ str(LOCAL_WIKI_DIR),
419
+ "commit",
420
+ "-m",
421
+ "[Wiki] Обновление документации",
422
+ ],
423
+ check=True,
424
+ )
425
+ subprocess.run(["git", "-C", str(LOCAL_WIKI_DIR), "push"], check=True)
426
+ print("[Wiki] Документация успешно обновлена и отправлена в Wiki.")
427
+ else:
428
+ print("[Wiki] Нет изменений для коммита.")
429
+
430
+
431
+ def main():
432
+ parser = argparse.ArgumentParser(description="Генерация документации проекта")
433
+ parser.add_argument(
434
+ "--push",
435
+ action="store_true",
436
+ help="Если указан, пушить документацию в GitHub Wiki",
437
+ )
438
+ args = parser.parse_args()
439
+
440
+ create_docs(PROJECT_DIRS, DOCS_DIR, EXCLUDE_DIRS)
441
+ print(f"Документация сгенерирована в {DOCS_DIR}")
442
+
443
+ if args.push:
444
+ push_to_wiki(DOCS_DIR)
@@ -0,0 +1,115 @@
1
+ """Модуль для валидации конфигураций."""
2
+
3
+ # Validate
4
+ from pathlib import Path
5
+ import re
6
+ from typing import List, Optional
7
+ from urllib.parse import urlparse
8
+ from gutarik.config_loader import validate_config as validate_config_keys
9
+
10
+
11
+ def validate_path(path: Path) -> None:
12
+ """Проверяет, что путь безопасен и корректен.
13
+
14
+ Args:
15
+ path (Path): Путь для проверки.
16
+ """
17
+ if not re.match(r"^[a-zA-Z0-9_/.-]+$", str(path)):
18
+ raise ValueError(f"Небезопасный путь: {path}")
19
+ if path.exists() and not path.is_dir():
20
+ raise ValueError(f"Путь {path} не является директорией")
21
+
22
+
23
+ def validate_project_dirs(dirs: List[Path]) -> None:
24
+ """Проверяет список директорий проекта.
25
+
26
+ Args:
27
+ dirs (List[Path]): Список директорий проекта.
28
+ """
29
+ for dir_path in dirs:
30
+ validate_path(dir_path)
31
+ if not dir_path.exists():
32
+ raise ValueError(f"Директория {dir_path} не существует")
33
+
34
+
35
+ def validate_exclude_dirs(dirs: List[Path]) -> None:
36
+ """Проверяет список исключаемых директорий.
37
+
38
+ Args:
39
+ dirs (List[Path]): Список исключаемых директорий.
40
+ """
41
+ if not isinstance(dirs, list):
42
+ raise ValueError("EXCLUDE_DIRS должен быть списком")
43
+ for dir_path in dirs:
44
+ if not isinstance(dir_path, (str, Path)):
45
+ raise ValueError(f"Некорректный путь в EXCLUDE_DIRS: {dir_path}")
46
+ validate_path(Path(dir_path))
47
+
48
+
49
+ def validate_docs_dir(docs_dir: Path) -> None:
50
+ """Проверяет директорию для документации.
51
+
52
+ Args:
53
+ docs_dir (Path): Директория для документации.
54
+ """
55
+ validate_path(docs_dir)
56
+ try:
57
+ docs_dir.mkdir(parents=True, exist_ok=True)
58
+ except Exception as e:
59
+ raise ValueError(f"Не удалось создать директорию {docs_dir}: {e}")
60
+
61
+
62
+ def validate_supported_ext(extensions: List[str]) -> None:
63
+ """Проверяет список поддерживаемых расширений.
64
+
65
+ Args:
66
+ extensions (List[str]): Список расширений.
67
+ """
68
+ for ext in extensions:
69
+ if not isinstance(ext, str) or not ext.startswith("."):
70
+ raise ValueError(f"Некорректное расширение: {ext}")
71
+
72
+
73
+ def validate_wiki_repo(url: str) -> None:
74
+ """Проверяет URL репозитория Wiki.
75
+
76
+ Args:
77
+ url (str): URL репозитория Wiki.
78
+ """
79
+ try:
80
+ parsed = urlparse(url)
81
+ if parsed.scheme not in ("https", "http"):
82
+ raise ValueError(f"URL {url} должен использовать схему http или https")
83
+ if not parsed.netloc or not parsed.path:
84
+ raise ValueError(f"Некорректный URL репозитория: {url}")
85
+ if not parsed.netloc.endswith("github.com") or not url.endswith(".wiki.git"):
86
+ raise ValueError(f"URL {url} не является валидным GitHub Wiki URL")
87
+ except Exception as e:
88
+ raise ValueError(f"Ошибка валидации URL {url}: {e}")
89
+
90
+
91
+ def validate_config(config: dict, config_path: Optional[str | Path] = None) -> None:
92
+ """Проверяет все конфигурационные переменные.
93
+
94
+ Args:
95
+ config (dict): Конфигурационный словарь.
96
+ config_path (str | Path, optional): Путь к конфигурационному файлу. Defaults to None.
97
+ """
98
+ if config_path:
99
+ config_path = Path(config_path)
100
+ if (
101
+ config_path.name != "pyproject.toml"
102
+ and config_path.name.split(".")[0] != "gutarik"
103
+ ):
104
+ raise ValueError(
105
+ f"Файл конфигурации должен начинаться с 'gutarik' или быть 'pyproject.toml', получено: {config_path.name}"
106
+ )
107
+ if config_path.suffix.lower() not in {".yml", ".yaml", ".toml", ".py"}:
108
+ raise ValueError(f"Формат {config_path.suffix} не поддерживается")
109
+ validate_config_keys(config)
110
+ validate_project_dirs([Path(p) for p in config["project_dirs"]])
111
+ validate_docs_dir(Path(config["docs_dir"]))
112
+ validate_supported_ext(config["supported_ext"])
113
+ validate_wiki_repo(config["wiki_repo"])
114
+ validate_path(Path(config["local_wiki_dir"]))
115
+ validate_exclude_dirs([Path(p) for p in config["exclude_dirs"]])
@@ -0,0 +1,35 @@
1
+ [tool.poetry]
2
+ name = "gutarik"
3
+ version = "0.1.0"
4
+ description = "Auto .md docs generator"
5
+ authors = ["ProudRykar <proudrykar@mail.ru>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ keywords = ["Backend","Documentation","Docs","Docstring","Generator","Python","Markdown"]
9
+ packages = [{include = "gutarik"}]
10
+
11
+ [tool.poetry.urls]
12
+ Repository = "https://github.com/ProudRykar/GUTARIK"
13
+ Issues = "https://github.com/ProudRykar/GUTARIK/issues"
14
+ Changelog = "https://github.com/ProudRykar/GUTARIK/releases"
15
+
16
+ [tool.poetry.dependencies]
17
+ python = ">=3.10"
18
+ pyyaml = {version = ">=6.0.2", optional = true}
19
+ toml = {version = "^0.10.2", optional = true}
20
+
21
+ [tool.poetry.group.dev.dependencies]
22
+ ruff = ">=0.14.0"
23
+ mypy = ">=1.15.0"
24
+ pre-commit = ">=4.1.0"
25
+ pytest = "^8.4.2"
26
+
27
+ [tool.poetry.group.docs.dependencies]
28
+ mkdocs = ">=1.6.1"
29
+ mkdocs-material = ">=9.6.19"
30
+
31
+ [tool.poetry.extras]
32
+ yaml_toml = ["pyyaml", "toml"]
33
+
34
+ [tool.poetry.scripts]
35
+ gutarik = "gutarik.generate_docs:main"