docs: compact layout, blue/green color coding, 1800px max-width; docs-generator SubParams support
This commit is contained in:
@@ -3,23 +3,36 @@ package types
|
|||||||
|
|
||||||
// ParamSpec — параметр операции.
|
// ParamSpec — параметр операции.
|
||||||
type ParamSpec struct {
|
type ParamSpec struct {
|
||||||
ID int `yaml:"id"`
|
ID int `yaml:"id"`
|
||||||
Code string `yaml:"code"`
|
Code string `yaml:"code"`
|
||||||
DataType string `yaml:"data_type"`
|
DataType string `yaml:"data_type"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Required bool `yaml:"required"`
|
Required bool `yaml:"required"`
|
||||||
Default interface{} `yaml:"default"`
|
Default interface{} `yaml:"default"`
|
||||||
Descr string `yaml:"descr"`
|
Descr string `yaml:"descr"`
|
||||||
Man string `yaml:"man"`
|
Man string `yaml:"man"`
|
||||||
RefSvcID *int `yaml:"ref_svc_id"`
|
RefSvcID *int `yaml:"ref_svc_id"`
|
||||||
Func string `yaml:"func"`
|
Func string `yaml:"func"`
|
||||||
MinValue *int `yaml:"minvalue"`
|
MinValue *int `yaml:"minvalue"`
|
||||||
MaxValue *int `yaml:"maxvalue"`
|
MaxValue *int `yaml:"maxvalue"`
|
||||||
Regex string `yaml:"regex"`
|
Regex string `yaml:"regex"`
|
||||||
ValueList []string `yaml:"value_list"`
|
ValueList []string `yaml:"value_list"`
|
||||||
Unique string `yaml:"unique_scope"`
|
Unique string `yaml:"unique_scope"`
|
||||||
MaxLength *int `yaml:"maxlength"`
|
MaxLength *int `yaml:"maxlength"`
|
||||||
MinLength *int `yaml:"minlength"`
|
MinLength *int `yaml:"minlength"`
|
||||||
|
IsJson bool `yaml:"is_json"`
|
||||||
|
HasSubParams bool `yaml:"has_sub_params"`
|
||||||
|
SubParams []ParamSpec `yaml:"sub_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixupSubParams устанавливает HasSubParams=true если есть sub_params.
|
||||||
|
func FixupSubParams(params []ParamSpec) {
|
||||||
|
for i := range params {
|
||||||
|
if len(params[i].SubParams) > 0 {
|
||||||
|
params[i].HasSubParams = true
|
||||||
|
FixupSubParams(params[i].SubParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OperationSpec — операция сервиса.
|
// OperationSpec — операция сервиса.
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ func exampleBlock(spec types.ServiceSpec, version string, apiEndpoint string) st
|
|||||||
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
|
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
|
||||||
|
|
||||||
for _, p := range requiredParams {
|
for _, p := range requiredParams {
|
||||||
b.WriteString(formatParamLine(p, " ", true))
|
b.WriteString(formatParamOrBlock(p, " ", true))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(defaultParams) > 0 {
|
if len(defaultParams) > 0 {
|
||||||
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
|
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
|
||||||
for _, p := range defaultParams {
|
for _, p := range defaultParams {
|
||||||
b.WriteString(formatParamLine(p, " ", false))
|
b.WriteString(formatParamOrBlock(p, " ", false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +205,15 @@ func buildCreateParamsPage(spec types.ServiceSpec, nav string) string {
|
|||||||
|
|
||||||
b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n")
|
b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n")
|
||||||
b.WriteString(renderParamTable(requiredParams, true, true))
|
b.WriteString(renderParamTable(requiredParams, true, true))
|
||||||
|
for _, p := range requiredParams {
|
||||||
|
b.WriteString(renderNestedParams(p))
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
|
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
|
||||||
b.WriteString(renderParamTable(defaultParams, false, false))
|
b.WriteString(renderParamTable(defaultParams, false, false))
|
||||||
|
for _, p := range defaultParams {
|
||||||
|
b.WriteString(renderNestedParams(p))
|
||||||
|
}
|
||||||
|
|
||||||
lifecycle := spec.Lifecycle
|
lifecycle := spec.Lifecycle
|
||||||
if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault {
|
if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault {
|
||||||
@@ -521,6 +527,57 @@ func formatParamLine(p types.ParamSpec, indent string, requiredOnly bool) string
|
|||||||
return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value)
|
return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatParamOrBlock форматирует параметр или вложенный HCL-блок (map-fixed).
|
||||||
|
func formatParamOrBlock(p types.ParamSpec, indent string, requiredOnly bool) string {
|
||||||
|
if requiredOnly && !p.Required {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Для map-fixed генерируем вложенный HCL-блок
|
||||||
|
if p.HasSubParams && len(p.SubParams) > 0 && !p.IsJson {
|
||||||
|
paramCode := ToSnake(p.Code)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode))
|
||||||
|
for _, sp := range p.SubParams {
|
||||||
|
spVal := sampleValue(sp)
|
||||||
|
spCode := ToSnake(sp.Code)
|
||||||
|
spComment := strings.TrimSpace(stripHTML(sp.Descr))
|
||||||
|
if spComment == "" {
|
||||||
|
spComment = strings.TrimSpace(stripHTML(sp.Man))
|
||||||
|
}
|
||||||
|
if spComment != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("%s}\n", indent))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
// Для array-map-fixed генерируем dynamic блок
|
||||||
|
if p.HasSubParams && len(p.SubParams) > 0 && p.IsJson {
|
||||||
|
paramCode := ToSnake(p.Code)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode))
|
||||||
|
b.WriteString(fmt.Sprintf("%s # Каждый элемент массива — объект с полями:\n", indent))
|
||||||
|
for _, sp := range p.SubParams {
|
||||||
|
spVal := sampleValue(sp)
|
||||||
|
spCode := ToSnake(sp.Code)
|
||||||
|
spComment := strings.TrimSpace(stripHTML(sp.Descr))
|
||||||
|
if spComment == "" {
|
||||||
|
spComment = strings.TrimSpace(stripHTML(sp.Man))
|
||||||
|
}
|
||||||
|
if spComment != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("%s}\n", indent))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
return formatParamLine(p, indent, requiredOnly)
|
||||||
|
}
|
||||||
|
|
||||||
func sampleValue(p types.ParamSpec) string {
|
func sampleValue(p types.ParamSpec) string {
|
||||||
if hasDefault(p.Default) {
|
if hasDefault(p.Default) {
|
||||||
return formatLiteral(p.Default)
|
return formatLiteral(p.Default)
|
||||||
@@ -857,3 +914,37 @@ func LoadCloudOutputSnapshot(root string) map[int]types.CloudOutputSnapshot {
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderNestedParams рендерит вложенные параметры (map-fixed/array-map-fixed).
|
||||||
|
func renderNestedParams(p types.ParamSpec) string {
|
||||||
|
if !p.HasSubParams || len(p.SubParams) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
label := p.Code
|
||||||
|
if p.IsJson {
|
||||||
|
label += " (array-map-fixed) — элемент"
|
||||||
|
} else {
|
||||||
|
label += " (map-fixed)"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("\n### %s\n\n", label))
|
||||||
|
b.WriteString("<table class=\"resource-table resource-table-compact resource-table-nested\">\n")
|
||||||
|
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Required</th><th>Default</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
|
||||||
|
for _, sp := range p.SubParams {
|
||||||
|
req := "no"
|
||||||
|
if sp.Required {
|
||||||
|
req = "**yes**"
|
||||||
|
}
|
||||||
|
b.WriteString("<tr>")
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(sp.ID))))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", formatParamCode(sp.Code)))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(sp)))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", req))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", defaultCell(sp.Default)))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickTextTable(sp))))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(sp))))
|
||||||
|
b.WriteString("</tr>\n")
|
||||||
|
}
|
||||||
|
b.WriteString("</tbody></table>\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ func loadSpecs(dir string, ordered []types.ServiceMeta) []types.ServiceSpec {
|
|||||||
if err := yaml.Unmarshal(b, &spec); err != nil {
|
if err := yaml.Unmarshal(b, &spec); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for i := range spec.Operations {
|
||||||
|
types.FixupSubParams(spec.Operations[i].Params)
|
||||||
|
}
|
||||||
specsByID[spec.ServiceID] = spec
|
specsByID[spec.ServiceID] = spec
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,69 +1,40 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""LLM-генератор документации для Nubes Terraform Provider.
|
|
||||||
Читает YAML-спеки, отправляет в LLM, сохраняет Markdown.
|
|
||||||
"""
|
"""
|
||||||
import json, os, sys, time, yaml
|
LLM-улучшатель документации Nubes Terraform Provider.
|
||||||
|
Прогоняет сгенерированные docs-generator'ом .md файлы через LLM,
|
||||||
|
чтобы сделать описания читаемыми и логичными.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python3 05_generate_docs_llm.py generated/test/docs
|
||||||
|
"""
|
||||||
|
import json, os, sys, time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from urllib.error import URLError
|
|
||||||
|
|
||||||
API_URL = "https://api.aillm.ru/v1/chat/completions"
|
API_URL = "https://api.aillm.ru/v1/chat/completions"
|
||||||
API_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"
|
API_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"
|
||||||
MODEL = "gpt-oss-120b"
|
MODEL = "gpt-oss-120b"
|
||||||
|
|
||||||
PROMPT = """Ты — генератор документации Terraform-провайдера Nubes Cloud.
|
SYSTEM_PROMPT = """Ты — технический писатель. Улучши ОДИН .md файл документации Terraform-провайдера: сделай описания грамотными и логичными.
|
||||||
Отвечай ТОЛЬКО Markdown-кодом. Никаких пояснений до или после.
|
|
||||||
|
|
||||||
⛔ ЖЁСТКИЕ ПРАВИЛА (нарушать нельзя):
|
ПРАВИЛА:
|
||||||
|
1. НЕ ВЫДУМЫВАЙ параметры, типы, значения. Только улучшай формулировки.
|
||||||
|
2. HTML-таблицы — меняй ТОЛЬКО текст внутри <td>...</td>. НЕ ломай теги.
|
||||||
|
3. HCL-блоки (```hcl ... ```) — НЕ ТРОГАТЬ вообще.
|
||||||
|
4. Navigation-строки — НЕ ТРОГАТЬ.
|
||||||
|
5. MAN-секцию с HTML — переведи в читаемый Markdown.
|
||||||
|
6. Верни ТОЛЬКО улучшенный Markdown. Без JSON, без пояснений, без ``` в начале/конце.
|
||||||
|
Просто готовый текст файла."""
|
||||||
|
|
||||||
1. НЕ ВЫДУМЫВАЙ параметры. Только те, что есть в YAML.
|
def call_llm(prompt: str) -> str:
|
||||||
Если у параметра нет description — напиши "—".
|
|
||||||
НИКОГДА не придумывай password, role, encoding и т.п.
|
|
||||||
|
|
||||||
2. Action-операции. ТОЛЬКО redeploy включается в документацию.
|
|
||||||
restart, recovery, reconcile — ИСКЛЮЧИТЬ.
|
|
||||||
|
|
||||||
3. Subresource-операции. Каждый subresource → отдельная секция.
|
|
||||||
Имя ресурса: nubes_{service}_{subresource}.
|
|
||||||
|
|
||||||
4. Lifecycle. suspend_on_destroy=true → "terraform destroy = Suspend".
|
|
||||||
adopt_existing_on_create → "terraform apply может подхватить существующий".
|
|
||||||
|
|
||||||
5. Outputs. Все поля из outputs.params. vault_secrets помечать как 🔒.
|
|
||||||
|
|
||||||
6. MAN. Если service_man есть — вставить как есть в секцию ## MAN.
|
|
||||||
|
|
||||||
7. ВЛОЖЕННЫЕ ПАРАМЕТРЫ (map-fixed/array-map-fixed).
|
|
||||||
Для каждого map-fixed параметра — показывать ВСЕ его sub_params как вложенную таблицу.
|
|
||||||
Для array-map-fixed — показывать структуру элемента.
|
|
||||||
Пример:
|
|
||||||
### clusterConfiguration (map-fixed)
|
|
||||||
| Параметр | Тип | Обязательный | По умолчанию | Описание |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| cpu | integer > 0 | да | 500 | Количество CPU в милликорах |
|
|
||||||
| memory | integer > 0 | да | 512 | Память в MB |
|
|
||||||
|
|
||||||
ФОРМАТ:
|
|
||||||
# Resource nubes_{name}
|
|
||||||
## MAN (если есть)
|
|
||||||
## Instance Operations (таблица)
|
|
||||||
## Create Parameters (таблица — для каждого map-fixed показывать sub_params)
|
|
||||||
## Modify Parameters (таблица)
|
|
||||||
## Subresources (таблица по каждому)
|
|
||||||
## Lifecycle
|
|
||||||
## Outputs (таблица)
|
|
||||||
|
|
||||||
YAML-спек:
|
|
||||||
```yaml
|
|
||||||
{yaml_content}
|
|
||||||
```"""
|
|
||||||
|
|
||||||
def call_llm(prompt_text: str) -> str:
|
|
||||||
data = json.dumps({
|
data = json.dumps({
|
||||||
"model": MODEL,
|
"model": MODEL,
|
||||||
"messages": [{"role": "user", "content": prompt_text}],
|
"messages": [
|
||||||
"temperature": 0.1,
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
"max_tokens": 8192,
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"temperature": 0.15,
|
||||||
|
"max_tokens": 4096,
|
||||||
}).encode()
|
}).encode()
|
||||||
req = Request(API_URL, data=data, headers={
|
req = Request(API_URL, data=data, headers={
|
||||||
"Authorization": f"Bearer {API_KEY}",
|
"Authorization": f"Bearer {API_KEY}",
|
||||||
@@ -73,62 +44,73 @@ def call_llm(prompt_text: str) -> str:
|
|||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=120) as resp:
|
with urlopen(req, timeout=120) as resp:
|
||||||
result = json.loads(resp.read())
|
result = json.loads(resp.read())
|
||||||
return result["choices"][0]["message"]["content"]
|
content = result["choices"][0]["message"]["content"].strip()
|
||||||
|
# Strip markdown fences if present
|
||||||
|
if content.startswith("```"):
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) > 2:
|
||||||
|
content = "\n".join(lines[1:-1])
|
||||||
|
return content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" retry {attempt+1}/3: {e}", file=sys.stderr)
|
print(f" retry {attempt+1}/3: {e}", file=sys.stderr)
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
raise RuntimeError("LLM failed after 3 retries")
|
raise RuntimeError("LLM failed after 3 retries")
|
||||||
|
|
||||||
def strip_service_man(yaml_text: str) -> str:
|
|
||||||
"""Убирает service_man (HTML-руководство) из YAML для сокращения токенов."""
|
|
||||||
lines = yaml_text.split('\n')
|
|
||||||
out = []
|
|
||||||
skip = False
|
|
||||||
for line in lines:
|
|
||||||
if line.strip().startswith('service_man:'):
|
|
||||||
out.append('service_man: "" # omitted for LLM token limit')
|
|
||||||
skip = True
|
|
||||||
continue
|
|
||||||
if skip:
|
|
||||||
# service_man может быть многострочным (HTML с отступами)
|
|
||||||
if line and line[0] not in (' ', '\t') and ':' in line:
|
|
||||||
skip = False
|
|
||||||
out.append(line)
|
|
||||||
continue
|
|
||||||
out.append(line)
|
|
||||||
return '\n'.join(out)
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
yaml_dir = sys.argv[1] if len(sys.argv) > 1 else "generated/test/resources_yaml"
|
docs_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("generated/test/docs")
|
||||||
docs_dir = sys.argv[2] if len(sys.argv) > 2 else "generated/test/docs_llm"
|
if not docs_dir.exists():
|
||||||
|
print(f"ERROR: {docs_dir} not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
os.makedirs(docs_dir, exist_ok=True)
|
# Process only main manual pages: Name.md (not _example, _params_*, _outputs, _ops)
|
||||||
yamls = sorted(Path(yaml_dir).glob("*.yaml"))
|
all_md = sorted(docs_dir.glob("*.md"))
|
||||||
print(f"Processing {len(yamls)} services...")
|
targets = []
|
||||||
|
for f in all_md:
|
||||||
|
name = f.stem
|
||||||
|
if name == "index":
|
||||||
|
continue
|
||||||
|
# Skip known non-manual suffixes
|
||||||
|
if any(name.endswith(sfx) for sfx in ["_example", "_params_create", "_params_modify", "_outputs", "_ops", "_params"]):
|
||||||
|
continue
|
||||||
|
# Also skip second-level (subresource) example files
|
||||||
|
if "_" in name:
|
||||||
|
# Could be subresource manual like "postgres_database"
|
||||||
|
# Check if there's a matching _example file
|
||||||
|
targets.append(f)
|
||||||
|
else:
|
||||||
|
targets.append(f)
|
||||||
|
|
||||||
|
print(f"Processing {len(targets)} manual pages...")
|
||||||
|
|
||||||
failed = []
|
failed = []
|
||||||
for i, yf in enumerate(yamls):
|
for i, f in enumerate(targets):
|
||||||
svc_name = yf.stem.split("_", 1)[1] if "_" in yf.stem else yf.stem
|
svc = f.stem
|
||||||
print(f" [{i+1}/{len(yamls)}] {svc_name}...", end=" ", flush=True)
|
print(f" [{i+1}/{len(targets)}] {svc}...", end=" ", flush=True)
|
||||||
|
|
||||||
yaml_text = strip_service_man(yf.read_text())
|
content = f.read_text()
|
||||||
prompt = PROMPT.replace("{yaml_content}", yaml_text)
|
# Truncate very long files
|
||||||
|
if len(content) > 12000:
|
||||||
|
content = content[:12000] + "\n\n... (обрезано для LLM)\n"
|
||||||
|
|
||||||
|
prompt = f"Улучши этот файл документации:\n\n=== {f.name} ===\n{content}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
md = call_llm(prompt)
|
improved = call_llm(prompt)
|
||||||
out = Path(docs_dir) / f"{yf.stem}.md"
|
if improved and len(improved) > 100:
|
||||||
out.write_text(md)
|
f.write_text(improved)
|
||||||
print("OK")
|
print("OK")
|
||||||
|
else:
|
||||||
|
print("SKIP (empty response)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"FAILED: {e}")
|
print(f"FAILED: {e}")
|
||||||
failed.append(svc_name)
|
failed.append(svc)
|
||||||
|
|
||||||
time.sleep(2) # rate limit
|
time.sleep(1.5)
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print(f"\nFailed ({len(failed)}): {', '.join(failed)}")
|
print(f"\nFailed ({len(failed)}): {', '.join(failed)}")
|
||||||
else:
|
else:
|
||||||
print(f"\nAll {len(yamls)} OK")
|
print(f"\nAll {len(targets)} OK")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user