small-software 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. small_cli/__init__.py +1 -0
  2. small_cli/__main__.py +3 -0
  3. small_cli/access.py +114 -0
  4. small_cli/archive.py +127 -0
  5. small_cli/cli.py +76 -0
  6. small_cli/commands/__init__.py +8 -0
  7. small_cli/commands/access.py +167 -0
  8. small_cli/commands/dashboard.py +49 -0
  9. small_cli/commands/db.py +154 -0
  10. small_cli/commands/deploy.py +248 -0
  11. small_cli/commands/destroy.py +63 -0
  12. small_cli/commands/domain.py +123 -0
  13. small_cli/commands/env.py +151 -0
  14. small_cli/commands/init.py +86 -0
  15. small_cli/commands/link.py +53 -0
  16. small_cli/commands/logs.py +59 -0
  17. small_cli/commands/mail.py +203 -0
  18. small_cli/commands/notify.py +147 -0
  19. small_cli/commands/sleep.py +61 -0
  20. small_cli/common.py +92 -0
  21. small_cli/config.py +82 -0
  22. small_cli/dashboard.py +648 -0
  23. small_cli/i18n.py +55 -0
  24. small_cli/railway.py +369 -0
  25. small_cli/scaffold.py +81 -0
  26. small_cli/templates/fastapi/magic_link.py +675 -0
  27. small_cli/templates/fastapi/main.py +67 -0
  28. small_cli/templates/fastapi/requirements.txt +2 -0
  29. small_cli/templates/node-http/magic-link.js +509 -0
  30. small_cli/templates/node-http/package.json +11 -0
  31. small_cli/templates/node-http/server.js +33 -0
  32. small_cli/templates/python-http/app.py +47 -0
  33. small_cli/templates/python-http/magic_link.py +675 -0
  34. small_cli/templates/streamlit/app.py +181 -0
  35. small_cli/templates/streamlit/requirements.txt +1 -0
  36. small_software-0.6.0.dist-info/METADATA +559 -0
  37. small_software-0.6.0.dist-info/RECORD +41 -0
  38. small_software-0.6.0.dist-info/WHEEL +5 -0
  39. small_software-0.6.0.dist-info/entry_points.txt +2 -0
  40. small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
  41. small_software-0.6.0.dist-info/top_level.txt +1 -0
small_cli/i18n.py ADDED
@@ -0,0 +1,55 @@
1
+ """Язык сообщений small: английский или русский.
2
+
3
+ SMALL_LANG=en|ru задаёт язык явно. Иначе — по локали: LC_ALL, LC_MESSAGES, LANG, LANGUAGE, а на Windows, где их
4
+ обычно нет, — по языку интерфейса системы. Всё, что не русский, — английский.
5
+
6
+ В коде: tr("Done", "Готово"). Язык выбирается при вызове, поэтому переводить надо внутри функций, а не в
7
+ константах модуля.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+
13
+ LANGS = ("en", "ru")
14
+ _lang = None
15
+
16
+
17
+ def detect(environ=None, platform=None):
18
+ environ = os.environ if environ is None else environ
19
+ platform = sys.platform if platform is None else platform
20
+ explicit = environ.get("SMALL_LANG", "").strip().lower()[:2]
21
+ if explicit in LANGS:
22
+ return explicit
23
+ for name in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
24
+ value = environ.get(name, "").strip()
25
+ if value and value != "POSIX" and not value.startswith("C"): # C, C.UTF-8 — локаль не выбрана
26
+ return "ru" if value.lower().startswith("ru") else "en"
27
+ if platform == "win32":
28
+ return "ru" if _windows_ui_language() & 0x3FF == 0x19 else "en" # 0x19 — LANG_RUSSIAN
29
+ return "en"
30
+
31
+
32
+ def _windows_ui_language():
33
+ try:
34
+ import ctypes
35
+
36
+ return ctypes.windll.kernel32.GetUserDefaultUILanguage()
37
+ except Exception: # не Windows или нет kernel32 — значит, не русский
38
+ return 0
39
+
40
+
41
+ def lang():
42
+ global _lang
43
+ if _lang is None:
44
+ _lang = detect()
45
+ return _lang
46
+
47
+
48
+ def use(language):
49
+ """Задать язык явно (тесты). None — снова определить по окружению."""
50
+ global _lang
51
+ _lang = language if language in LANGS else None
52
+
53
+
54
+ def tr(en, ru):
55
+ return ru if lang() == "ru" else en
small_cli/railway.py ADDED
@@ -0,0 +1,369 @@
1
+ """Тонкий клиент Railway: GraphQL API v2 и загрузка исходников (тот же эндпоинт, что у `railway up`)."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from datetime import datetime, timezone
9
+
10
+ from . import __version__
11
+ from .i18n import tr
12
+
13
+ DEFAULT_API_URL = "https://backboard.railway.com"
14
+ TOKEN_ENV_VARS = ("RAILWAY_API_TOKEN", "RAILWAY_TOKEN")
15
+ CUSTOM_DOMAIN_STATUS = ("dnsRecords { recordType hostlabel fqdn requiredValue currentValue status purpose } "
16
+ "verified verificationDnsHost verificationToken certificateStatus")
17
+
18
+
19
+ class RailwayError(Exception):
20
+ pass
21
+
22
+
23
+ def token_from_env():
24
+ """Токен Railway из окружения. Пробелы, кавычки и угловые скобки по краям отбрасываются: в токене их не бывает,
25
+ а из заготовки вида "<токен>" их легко скопировать вместе со значением."""
26
+ raw = next((os.environ[v] for v in TOKEN_ENV_VARS if os.environ.get(v)), "")
27
+ return raw.strip().strip("\"'<>").strip() or None
28
+
29
+
30
+ class Railway:
31
+ def __init__(self, token, base_url=None):
32
+ self.token = token
33
+ self.base_url = (base_url or os.environ.get("SMALL_API_URL") or DEFAULT_API_URL).rstrip("/")
34
+
35
+ # --- транспорт ---
36
+
37
+ def _post(self, url, body, content_type, timeout=60):
38
+ req = urllib.request.Request(url, data=body, method="POST", headers={
39
+ "Authorization": "Bearer " + self.token,
40
+ "Content-Type": content_type,
41
+ "Accept": "application/json",
42
+ "User-Agent": "small-cli/" + __version__,
43
+ })
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
46
+ raw = resp.read()
47
+ except urllib.error.HTTPError as e:
48
+ raise RailwayError("HTTP %d: %s" % (e.code, _error_text(e.read()))) from None
49
+ except OSError as e: # URLError, таймауты, разрыв соединения
50
+ raise RailwayError(tr("cannot reach %s: %s", "нет связи с %s: %s")
51
+ % (self.base_url, getattr(e, "reason", e))) from None
52
+ try:
53
+ return json.loads(raw or b"{}")
54
+ except ValueError:
55
+ raise RailwayError(tr("unexpected server response: %s", "неожиданный ответ сервера: %s")
56
+ % _error_text(raw)) from None
57
+
58
+ def graphql(self, query, variables=None, timeout=60):
59
+ body = json.dumps({"query": query, "variables": variables or {}}).encode("utf-8")
60
+ res = self._post(self.base_url + "/graphql/v2", body, "application/json", timeout=timeout)
61
+ if res.get("errors"):
62
+ raise RailwayError("; ".join(e.get("message", "?") for e in res["errors"]))
63
+ return res.get("data") or {}
64
+
65
+ # --- операции ---
66
+
67
+ def workspaces(self):
68
+ """Работает только с токеном аккаунта; токен workspace получит ошибку."""
69
+ data = self.graphql("query { me { workspaces { id name } } }")
70
+ return data["me"]["workspaces"]
71
+
72
+ def create_project(self, name, workspace_id=None):
73
+ inp = {"name": name}
74
+ if workspace_id:
75
+ inp["workspaceId"] = workspace_id
76
+ data = self.graphql(
77
+ """mutation($input: ProjectCreateInput!) {
78
+ projectCreate(input: $input) { id environments { edges { node { id name } } } }
79
+ }""",
80
+ {"input": inp},
81
+ )
82
+ project = data["projectCreate"]
83
+ envs = [edge["node"] for edge in project["environments"]["edges"]]
84
+ if not envs:
85
+ raise RailwayError(tr("the new project has no environments", "у созданного проекта нет окружений"))
86
+ env = next((e for e in envs if e["name"] == "production"), envs[0])
87
+ return project["id"], env["id"]
88
+
89
+ def find_projects(self, name, workspace_id=None):
90
+ """Неудалённые проекты с точно таким именем, вместе с окружениями и сервисами."""
91
+ data = self.graphql(
92
+ """query($workspaceId: String) {
93
+ projects(workspaceId: $workspaceId, first: 500) { edges { node {
94
+ id name deletedAt
95
+ environments { edges { node { id name } } }
96
+ services { edges { node { id name } } }
97
+ } } }
98
+ }""",
99
+ {"workspaceId": workspace_id},
100
+ )
101
+ found = []
102
+ for edge in data["projects"]["edges"]:
103
+ node = edge["node"]
104
+ if node["name"] == name and not node.get("deletedAt"):
105
+ found.append({
106
+ "id": node["id"],
107
+ "environments": [e["node"] for e in node["environments"]["edges"]],
108
+ "services": [s["node"] for s in node["services"]["edges"]],
109
+ })
110
+ return found
111
+
112
+ def create_service(self, project_id, environment_id, name):
113
+ data = self.graphql(
114
+ "mutation($input: ServiceCreateInput!) { serviceCreate(input: $input) { id } }",
115
+ {"input": {"projectId": project_id, "environmentId": environment_id, "name": name}},
116
+ )
117
+ return data["serviceCreate"]["id"]
118
+
119
+ def upsert_variables(self, project_id, environment_id, service_id, variables):
120
+ """Добавляет/обновляет переменные сервиса одним запросом. Остальные переменные не трогает,
121
+ повторный деплой не запускает: их подхватит следующая сборка."""
122
+ self.graphql(
123
+ "mutation($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }",
124
+ {"input": {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id,
125
+ "variables": variables, "replace": False, "skipDeploys": True}},
126
+ )
127
+
128
+ def upload(self, project_id, environment_id, service_id, tarball, message=None):
129
+ """Загружает .tar.gz с кодом и запускает сборку. Возвращает {deploymentId, url, logsUrl, ...}."""
130
+ params = {"serviceId": service_id}
131
+ if message:
132
+ params["message"] = message
133
+ url = "%s/project/%s/environment/%s/up?%s" % (
134
+ self.base_url, project_id, environment_id, urllib.parse.urlencode(params))
135
+ res = self._post(url, tarball, "application/gzip", timeout=600)
136
+ if not res.get("deploymentId"):
137
+ raise RailwayError(tr("Railway did not return a deploymentId: %s",
138
+ "Railway не вернул deploymentId: %s") % res)
139
+ return res
140
+
141
+ def create_domain(self, service_id, environment_id, port=None):
142
+ inp = {"serviceId": service_id, "environmentId": environment_id}
143
+ if port:
144
+ inp["targetPort"] = port
145
+ data = self.graphql(
146
+ "mutation($input: ServiceDomainCreateInput!) { serviceDomainCreate(input: $input) { id domain } }",
147
+ {"input": inp},
148
+ )
149
+ domain = data["serviceDomainCreate"]
150
+ return domain["id"], domain["domain"]
151
+
152
+ def domain_available(self, domain):
153
+ data = self.graphql(
154
+ "query($domain: String!) { serviceDomainAvailable(domain: $domain) { available } }",
155
+ {"domain": domain},
156
+ )
157
+ return bool(data["serviceDomainAvailable"]["available"])
158
+
159
+ def rename_domain(self, domain_id, service_id, environment_id, domain, port=None):
160
+ inp = {"serviceDomainId": domain_id, "serviceId": service_id,
161
+ "environmentId": environment_id, "domain": domain}
162
+ if port:
163
+ inp["targetPort"] = port
164
+ self.graphql("mutation($input: ServiceDomainUpdateInput!) { serviceDomainUpdate(input: $input) }",
165
+ {"input": inp})
166
+
167
+ def service_domains(self, project_id, environment_id, service_id):
168
+ data = self.graphql(
169
+ """query($p: String!, $e: String!, $s: String!) {
170
+ domains(projectId: $p, environmentId: $e, serviceId: $s) { serviceDomains { id domain } }
171
+ }""",
172
+ {"p": project_id, "e": environment_id, "s": service_id},
173
+ )
174
+ return data["domains"]["serviceDomains"]
175
+
176
+ def service_domain(self, project_id, environment_id, service_id, domain_id):
177
+ for d in self.service_domains(project_id, environment_id, service_id):
178
+ if d["id"] == domain_id:
179
+ return d["domain"]
180
+ return None
181
+
182
+ def latest_deployment(self, service_id, environment_id, timeout=60):
183
+ """{"id", "status", "createdAt"} последнего деплоя сервиса или None, если деплоев не было."""
184
+ data = self.graphql(
185
+ """query($serviceId: String!, $environmentId: String!) {
186
+ serviceInstance(serviceId: $serviceId, environmentId: $environmentId) {
187
+ latestDeployment { id status createdAt }
188
+ }
189
+ }""",
190
+ {"serviceId": service_id, "environmentId": environment_id},
191
+ timeout=timeout,
192
+ )
193
+ return data["serviceInstance"]["latestDeployment"]
194
+
195
+ def instance_status(self, service_id, environment_id, timeout=60):
196
+ """{"sleep": включён ли режим сна, "deployment": последний деплой или None}."""
197
+ data = self.graphql(
198
+ """query($serviceId: String!, $environmentId: String!) {
199
+ serviceInstance(serviceId: $serviceId, environmentId: $environmentId) {
200
+ sleepApplication
201
+ latestDeployment { id status createdAt }
202
+ }
203
+ }""",
204
+ {"serviceId": service_id, "environmentId": environment_id},
205
+ timeout=timeout,
206
+ )
207
+ instance = data["serviceInstance"]
208
+ return {"sleep": bool(instance.get("sleepApplication")), "deployment": instance["latestDeployment"]}
209
+
210
+ def update_instance(self, service_id, environment_id, **settings):
211
+ """Настройки сервиса в окружении (sleepApplication, startCommand...). Вступают в силу со следующим деплоем."""
212
+ self.graphql(
213
+ "mutation($s: String!, $e: String, $input: ServiceInstanceUpdateInput!) "
214
+ "{ serviceInstanceUpdate(serviceId: $s, environmentId: $e, input: $input) }",
215
+ {"s": service_id, "e": environment_id, "input": settings},
216
+ )
217
+
218
+ def average_load(self, project_id, start, end, step_seconds=3600, timeout=60):
219
+ """Средняя нагрузка проекта (все его сервисы, включая базы) за период: {"CPU_USAGE": ядра,
220
+ "MEMORY_USAGE_GB": ГБ, "DISK_USAGE_GB": ГБ}.
221
+
222
+ Берём metrics, а не usage: у metrics известные единицы (средние ядра и ГБ за интервал), а usage
223
+ накапливается с задержкой. Для каждого сервиса среднее считается с его первого замера до конца периода,
224
+ пропуски (сервис спал) — как ноль; затем средние сервисов складываются.
225
+ """
226
+ data = self.graphql(
227
+ """query($p: String!, $start: DateTime!, $end: DateTime!, $step: Int!) {
228
+ metrics(projectId: $p, startDate: $start, endDate: $end, sampleRateSeconds: $step,
229
+ averagingWindowSeconds: $step, groupBy: [SERVICE_ID],
230
+ measurements: [CPU_USAGE, MEMORY_USAGE_GB, DISK_USAGE_GB]) {
231
+ measurement values { ts value }
232
+ }
233
+ }""",
234
+ {"p": project_id, "start": start, "end": end, "step": step_seconds},
235
+ timeout=timeout,
236
+ )
237
+ end_ts = int(_parse_time(end))
238
+ load = {}
239
+ for series in data["metrics"]:
240
+ values = series["values"]
241
+ if not values:
242
+ continue
243
+ expected = max(1, (end_ts - min(v["ts"] for v in values)) // step_seconds + 1)
244
+ average = sum(float(v["value"]) for v in values) / max(expected, len(values))
245
+ load[series["measurement"]] = load.get(series["measurement"], 0.0) + average
246
+ return load
247
+
248
+ def template(self, code):
249
+ data = self.graphql("query($code: String!) { template(code: $code) { id name serializedConfig } }",
250
+ {"code": code})
251
+ return data["template"]
252
+
253
+ def deploy_template(self, project_id, environment_id, template_id, config):
254
+ data = self.graphql(
255
+ """mutation($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }""",
256
+ {"input": {"projectId": project_id, "environmentId": environment_id, "templateId": template_id,
257
+ "serializedConfig": config}},
258
+ )
259
+ return data["templateDeployV2"].get("workflowId")
260
+
261
+ def workflow_status(self, workflow_id):
262
+ data = self.graphql("query($id: String!) { workflowStatus(workflowId: $id) { status error } }",
263
+ {"id": workflow_id})
264
+ return data["workflowStatus"]
265
+
266
+ def custom_domains(self, project_id, environment_id, service_id):
267
+ data = self.graphql(
268
+ """query($p: String!, $e: String!, $s: String!) {
269
+ domains(projectId: $p, environmentId: $e, serviceId: $s) {
270
+ customDomains { id domain targetPort status { %s } }
271
+ }
272
+ }""" % CUSTOM_DOMAIN_STATUS,
273
+ {"p": project_id, "e": environment_id, "s": service_id},
274
+ )
275
+ return data["domains"]["customDomains"]
276
+
277
+ def create_custom_domain(self, project_id, environment_id, service_id, domain, port=None):
278
+ inp = {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id, "domain": domain}
279
+ if port:
280
+ inp["targetPort"] = port
281
+ data = self.graphql(
282
+ "mutation($input: CustomDomainCreateInput!) "
283
+ "{ customDomainCreate(input: $input) { id domain targetPort status { %s } } }" % CUSTOM_DOMAIN_STATUS,
284
+ {"input": inp},
285
+ )
286
+ return data["customDomainCreate"]
287
+
288
+ def delete_custom_domain(self, domain_id):
289
+ self.graphql("mutation($id: String!) { customDomainDelete(id: $id) }", {"id": domain_id})
290
+
291
+ def deployment_status(self, deployment_id):
292
+ data = self.graphql("query($id: String!) { deployment(id: $id) { status } }", {"id": deployment_id})
293
+ return data["deployment"]["status"]
294
+
295
+ def log_entries(self, deployment_id, build=False, limit=100, start_date=None):
296
+ """Строки лога: [{"timestamp", "message", "severity"}]. start_date (ISO) — только строки начиная с него."""
297
+ field = "buildLogs" if build else "deploymentLogs"
298
+ data = self.graphql(
299
+ "query($id: String!, $limit: Int, $startDate: DateTime) "
300
+ "{ %s(deploymentId: $id, limit: $limit, startDate: $startDate) { timestamp message severity } }" % field,
301
+ {"id": deployment_id, "limit": limit, "startDate": start_date},
302
+ )
303
+ return data[field]
304
+
305
+ def logs(self, deployment_id, build=True, limit=40):
306
+ return [entry["message"] for entry in self.log_entries(deployment_id, build=build, limit=limit)]
307
+
308
+ def service_variables(self, project_id, environment_id, service_id, unrendered=False):
309
+ """Переменные сервиса. unrendered=True — как записаны, без подстановки ссылок: `${{Postgres.DATABASE_URL}}`,
310
+ а не готовый адрес с паролем."""
311
+ ids = {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id}
312
+ if not unrendered:
313
+ data = self.graphql(
314
+ "query($projectId: String!, $environmentId: String!, $serviceId: String) "
315
+ "{ variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) }", ids)
316
+ else:
317
+ data = self.graphql(
318
+ "query($projectId: String!, $environmentId: String!, $serviceId: String, $unrendered: Boolean) "
319
+ "{ variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId, "
320
+ "unrendered: $unrendered) }", dict(ids, unrendered=True))
321
+ return data["variables"]
322
+
323
+ def project_services(self, project_id):
324
+ """(имя проекта, [{"id", "name"}] его сервисов)."""
325
+ data = self.graphql(
326
+ "query($id: String!) { project(id: $id) { name services { edges { node { id name } } } } }",
327
+ {"id": project_id},
328
+ )
329
+ project = data["project"]
330
+ return project["name"], [edge["node"] for edge in project["services"]["edges"]]
331
+
332
+ def delete_variable(self, project_id, environment_id, service_id, name):
333
+ self.graphql(
334
+ "mutation($input: VariableDeleteInput!) { variableDelete(input: $input) }",
335
+ {"input": {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id,
336
+ "name": name}},
337
+ )
338
+
339
+ def redeploy(self, service_id, environment_id):
340
+ """Перезапустить сервис с текущими переменными (последний образ пересобирать не нужно)."""
341
+ self.graphql(
342
+ "mutation($s: String!, $e: String!) { serviceInstanceRedeploy(serviceId: $s, environmentId: $e) }",
343
+ {"s": service_id, "e": environment_id},
344
+ )
345
+
346
+ def delete_project(self, project_id):
347
+ self.graphql("mutation($id: String!) { projectDelete(id: $id) }", {"id": project_id})
348
+
349
+ def delete_service(self, service_id):
350
+ self.graphql("mutation($id: String!) { serviceDelete(id: $id) }", {"id": service_id})
351
+
352
+
353
+ def _parse_time(iso):
354
+ """'2026-10-10T12:00:00Z' -> unix-время."""
355
+ return datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc).timestamp()
356
+
357
+
358
+ def _error_text(raw):
359
+ text = raw.decode("utf-8", "replace").strip() if raw else ""
360
+ try:
361
+ payload = json.loads(text)
362
+ except ValueError:
363
+ return text[:300] or tr("(empty response)", "(пустой ответ)")
364
+ if isinstance(payload, dict):
365
+ if payload.get("message"):
366
+ return payload["message"]
367
+ if payload.get("errors"):
368
+ return "; ".join(e.get("message", "?") for e in payload["errors"])
369
+ return text[:300]
small_cli/scaffold.py ADDED
@@ -0,0 +1,81 @@
1
+ """Шаблоны для `small init`: файлы приложения и правила .gitignore / .railwayignore."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .i18n import tr
6
+
7
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
8
+ DEFAULT_TEMPLATE = "python-http"
9
+
10
+ # description — (английский, русский): язык выбирается при вызове, см. description().
11
+ TEMPLATES = {
12
+ "python-http": {
13
+ "description": ("Python 3, standard library (wsgiref) + magic_link.py",
14
+ "Python 3, стандартная библиотека (wsgiref) + magic_link.py"),
15
+ "gitignore": ["__pycache__/", "*.pyc", ".venv/", "venv/"],
16
+ },
17
+ "node-http": {
18
+ "description": ("Node.js 18+, http module + magic-link.js",
19
+ "Node.js 18+, модуль http + magic-link.js"),
20
+ "gitignore": ["node_modules/", "npm-debug.log*"],
21
+ },
22
+ "fastapi": {
23
+ "description": ("FastAPI + uvicorn (ASGI), protected by MagicLinkASGI from magic_link.py",
24
+ "FastAPI + uvicorn (ASGI), защита MagicLinkASGI из magic_link.py"),
25
+ "gitignore": ["__pycache__/", "*.pyc", ".venv/", "venv/"],
26
+ },
27
+ "streamlit": {
28
+ "description": ("Streamlit: interactive Python UI, invite-link sign-in built into app.py",
29
+ "Streamlit: интерактивный интерфейс на Python, вход по ссылке встроен в app.py"),
30
+ "gitignore": ["__pycache__/", "*.pyc", ".venv/", "venv/", ".streamlit/secrets.toml"],
31
+ },
32
+ }
33
+
34
+ # small.json хранит ACCESS_TOKEN и другие секреты, .small.json — ID проекта на этой машине.
35
+ GITIGNORE_COMMON = ["small.json", ".small.json", ".env"]
36
+ # Нужно в git, но не нужно в Railway.
37
+ RAILWAYIGNORE = ["*.md", "tests/", ".vscode/", ".idea/"]
38
+
39
+ IGNORE_HEADERS = { # (английский, русский)
40
+ ".gitignore": ("# Do not commit", "# Не коммитить"),
41
+ ".railwayignore": ("# Do not upload to Railway (in addition to .gitignore)",
42
+ "# Не загружать в Railway (в дополнение к .gitignore)"),
43
+ }
44
+
45
+
46
+ def description(template):
47
+ """Описание шаблона на языке пользователя (для `small init --help`)."""
48
+ return tr(*TEMPLATES[template]["description"])
49
+
50
+
51
+ def template_files(template):
52
+ """{имя файла: содержимое} файлов приложения из шаблона."""
53
+ folder = TEMPLATES_DIR / template
54
+ return {p.name: p.read_bytes() for p in sorted(folder.iterdir()) if p.is_file()}
55
+
56
+
57
+ def ignore_rules(template):
58
+ return {
59
+ ".gitignore": GITIGNORE_COMMON + TEMPLATES[template]["gitignore"],
60
+ ".railwayignore": RAILWAYIGNORE,
61
+ }
62
+
63
+
64
+ def merge_ignore(path, lines):
65
+ """Создаёт ignore-файл или дописывает в него недостающие строки, ничего не удаляя.
66
+
67
+ Возвращает "created", "updated" или "unchanged".
68
+ """
69
+ exists = path.is_file()
70
+ text = path.read_text(encoding="utf-8-sig") if exists else ""
71
+ present = {line.strip() for line in text.splitlines()}
72
+ missing = [line for line in lines if line not in present]
73
+ if not missing:
74
+ return "unchanged"
75
+ if text and not text.endswith("\n"):
76
+ text += "\n"
77
+ header = tr("\n# Added by small init\n", "\n# Добавлено small init\n") if exists \
78
+ else tr(*IGNORE_HEADERS[path.name]) + "\n"
79
+ text += header + "\n".join(missing) + "\n"
80
+ path.write_bytes(text.encode("utf-8"))
81
+ return "updated" if exists else "created"