kcm-workspace 0.1.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.
@@ -0,0 +1,5 @@
1
+ from .color import Color
2
+ from .workspace import HOME, GitClone, Workspace
3
+
4
+ __all__ = ["HOME", "Color", "GitClone", "Workspace"]
5
+ __version__ = "0.1.0"
kcm_workspace/color.py ADDED
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+
4
+ class Color:
5
+ ENDC = "\033[0m"
6
+
7
+ @staticmethod
8
+ def r(text: str | Path) -> str:
9
+ """Returns a *red* string"""
10
+ return f"\033[91m{text}{Color.ENDC}"
11
+
12
+ @staticmethod
13
+ def b(text: str | Path) -> str:
14
+ """Returns a *blue* string"""
15
+ return f"\033[94m{text}{Color.ENDC}"
16
+
17
+ @staticmethod
18
+ def g(text: str | Path) -> str:
19
+ """Returns a *green* string"""
20
+ return f"\033[92m{text}{Color.ENDC}"
21
+
22
+ @staticmethod
23
+ def c(text: str | Path) -> str:
24
+ """Returns a *cyan* string"""
25
+ return f"\033[96m{text}{Color.ENDC}"
26
+
27
+ @staticmethod
28
+ def m(text: str | Path) -> str:
29
+ """Returns a *magenta* string"""
30
+ return f"\033[95m{text}{Color.ENDC}"
31
+
32
+ @staticmethod
33
+ def y(text: str | Path) -> str:
34
+ """Returns a *yellow* string"""
35
+ return f"\033[93m{text}{Color.ENDC}"
36
+
37
+ @staticmethod
38
+ def s(text: str | Path) -> str:
39
+ """Returns a *strong* string"""
40
+ return f"\033[1m{text}{Color.ENDC}"
41
+
42
+ @staticmethod
43
+ def u(text: str | Path) -> str:
44
+ """Returns a *underlined* string"""
45
+ return f"\033[4m{text}{Color.ENDC}"
@@ -0,0 +1,425 @@
1
+ import glob
2
+ import inspect
3
+ import os
4
+ import re
5
+ import subprocess # nosec B404
6
+ import sys
7
+ import time
8
+ import zipfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .color import Color
13
+
14
+ HOME = Path(os.environ.get("HOME") or os.environ.get("USERPROFILE") or os.path.expanduser("~"))
15
+ WINDOWS_HOSTS = Path(r"C:\Windows\System32\drivers\etc\hosts")
16
+ POSIX_HOSTS = Path("/etc/hosts")
17
+
18
+ NATIVES = {
19
+ "build": "Build or rebuild services.",
20
+ "convert": "Converts the compose file to platform's canonical format.",
21
+ "cp": "Copy files/folders between a service container and the local filesystem.",
22
+ "create": "Creates containers for a service.",
23
+ "down": "Stop and remove containers: networks.",
24
+ "events": "Receive real time events from containers.",
25
+ "exec": "Execute a command in a running container.",
26
+ "images": "List images used by the created containers.",
27
+ "kill": "Force stop service containers.",
28
+ "logs": "View output from containers.",
29
+ "ls": "List running compose projects.",
30
+ "pause": "Pause services.",
31
+ "port": "Print the public port for a port binding.",
32
+ "ps": "List containers.",
33
+ "pull": "Pull service images.",
34
+ "push": "Push service images.",
35
+ "restart": "Restart service containers.",
36
+ "rm": "Removes stopped service containers.",
37
+ "run": "Run a one-off command on a service.",
38
+ "start": "Start services.",
39
+ "stop": "Stop services.",
40
+ "top": "Display the running processes.",
41
+ "unpause": "Unpause services.,",
42
+ "up": "Create and start containers.",
43
+ "version": "Show the Docker Compose version information.",
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class GitClone:
49
+ """Repositório a ser clonado. ``folder`` relativo é resolvido a partir da pasta do projeto."""
50
+
51
+ folder: str | Path
52
+ url: str
53
+ branch: str = "main"
54
+
55
+
56
+ class Workspace:
57
+ """Base dos scripts de workspace.
58
+
59
+ Cada projeto estende esta classe e informa no construtor o que é específico dele.
60
+ Um método vira comando quando a docstring termina com ``Aplica-se a: <escopos>``.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ script_file: str | Path,
66
+ git_clones: list[GitClone] | None = None,
67
+ mk_volumes: list[str] | None = None,
68
+ moodle_plugins: list[str] | None = None,
69
+ cli_dict: dict[str, str] | None = None,
70
+ shell_dict: dict[str, str] | None = None,
71
+ argv: list[str] | None = None,
72
+ ):
73
+ self.script = Path(script_file).resolve()
74
+ self.project_name = self.script.stem
75
+ self.workspace_dir = self.script.parent
76
+ self.project_dir = self.workspace_dir.parent
77
+ self.volumes_dir = Path(os.environ.get("VOLUMES_DIR", self.project_dir / "volumes"))
78
+ self.home = HOME
79
+
80
+ self.git_clones = [GitClone(self.project_dir / g.folder, g.url, g.branch) for g in (git_clones or [])]
81
+ self.mk_volumes = mk_volumes or []
82
+ self.moodle_plugins = moodle_plugins or []
83
+ self.cli_dict = cli_dict or {}
84
+ self.shell_dict = shell_dict or {}
85
+
86
+ self.argv = list(sys.argv if argv is None else argv)
87
+ self._command_name = self.argv[1] if len(self.argv) > 1 else "help"
88
+ self._scope_name = self.argv[2] if len(self.argv) >= 3 else "env"
89
+ self._service_name = self._scope_name if self._scope_name != "env" else ""
90
+
91
+ self._services: list[str] = []
92
+ self._repositories: set[str] = set()
93
+ self._commands: dict = {}
94
+ self._scopes: dict[str, list[str]] = {}
95
+ self._descriptions: dict[str, str] = {}
96
+
97
+ # ------------------------------------------------------------------ execução
98
+
99
+ def run(self) -> int:
100
+ """Lê .env e docker compose, carrega os comandos e executa o solicitado."""
101
+ try:
102
+ os.chdir(self.workspace_dir)
103
+ print(f"Trabalhando em {Color.c(self.workspace_dir)}")
104
+ self._read_envs()
105
+ self._read_compose()
106
+ self._load_commands()
107
+ if len(self.argv) == 1:
108
+ return self.help()
109
+ if self._command_name not in self._commands:
110
+ print(f"{Color.g('docker compose')} {Color.b(self._command_name)} {Color.m(' '.join(self.argv[2:]))}")
111
+ return self._execute("docker", "compose", *self.argv[1:])
112
+ return self._commands[self._command_name]() or 0
113
+ except KeyboardInterrupt:
114
+ print(f"\n{Color.r('Interrompido pelo usuário')}")
115
+ return 130
116
+
117
+ @classmethod
118
+ def main(cls) -> None:
119
+ """Ponto de entrada: ``if __name__ == "__main__": MeuProjeto.main()``."""
120
+ sys.exit(cls().run())
121
+
122
+ # ------------------------------------------------------------------ internos
123
+
124
+ def _read_envs(self) -> None:
125
+ env_file = self.workspace_dir / ".env"
126
+ if not env_file.exists():
127
+ return
128
+ try:
129
+ content = env_file.read_text(encoding="utf-8")
130
+ except UnicodeDecodeError:
131
+ content = env_file.read_text()
132
+ for line in content.splitlines():
133
+ if line.startswith("#") or line.strip() == "" or "=" not in line:
134
+ continue
135
+ varname, varval = line.split("=", 1)
136
+ if varname == "COMPOSE_PROJECT_NAME" and varval != self.project_name:
137
+ raise ValueError(f"No arquivo .env está como {line}, mas devia ser {varname}={self.project_name}")
138
+ os.environ[varname] = varval
139
+
140
+ def _read_compose(self) -> None:
141
+ result = subprocess.run( # nosec B603 B607
142
+ ["docker", "compose", "config"], capture_output=True, text=True, cwd=self.workspace_dir
143
+ )
144
+ self._services = []
145
+ repositories = [""]
146
+ for line in result.stdout.splitlines():
147
+ service = re.search(r"^ (\w*):$", line)
148
+ if service and service.group(1) != "default":
149
+ self._services.append(service.group(1))
150
+ image = re.search(r"^( image: )(.*)$", line)
151
+ if image:
152
+ name = image.group(2)
153
+ repositories.append(name.split("/")[0] if name.count("/") > 1 else "")
154
+ self._repositories = set(repositories)
155
+
156
+ def _load_commands(self) -> None:
157
+ for name, func in inspect.getmembers(type(self), inspect.isfunction):
158
+ match = re.search(r"^(.*)(Aplica-se a: )(.*)$", func.__doc__ or "", re.MULTILINE)
159
+ if match is None:
160
+ continue
161
+ scopes = match.group(3)
162
+ scopes = scopes.replace("$services_names$", ",".join(self._services))
163
+ scopes = scopes.replace("$restore_dirs$", ",".join(self._prefixed_envs("RESTORE_DIR_").keys()))
164
+ scopes = scopes.replace("$download_files$", ",".join(self._prefixed_envs("DOWNLOAD_FILE").keys()))
165
+ self._commands[name] = getattr(self, name)
166
+ self._scopes[name] = scopes.split(",")
167
+ self._descriptions[name] = match.group(1) + match.group(2)
168
+
169
+ def _container_lines(self) -> list[str]:
170
+ result = subprocess.run(["docker", "container", "ps"], capture_output=True, text=True) # nosec B603 B607
171
+ return result.stdout.splitlines()
172
+
173
+ def _service_is_running(self, service_name: str) -> bool:
174
+ pattern = f".*{self.project_name}-{service_name}-1.*"
175
+ return any(re.match(pattern, line) for line in self._container_lines())
176
+
177
+ def _service_is_healthy(self, service_name: str) -> bool:
178
+ pattern = f".*{self.project_name}-{service_name}-1.*"
179
+ return any(re.match(pattern, line) and "healthy" in line for line in self._container_lines())
180
+
181
+ def _execute(self, executable_file: str, *args: str) -> int:
182
+ command = " ".join([executable_file, *args])
183
+ print(Color.g("Executando: "), Color.b(command))
184
+ p = subprocess.Popen( # nosec B602
185
+ command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stdout, shell=True, cwd=self.workspace_dir
186
+ )
187
+ return p.wait()
188
+
189
+ def _write_shell_file(self, filename: str) -> None:
190
+ shell_file = self.home / filename
191
+ if shell_file.exists() and str(self.workspace_dir) not in shell_file.read_text(encoding="utf-8"):
192
+ with shell_file.open("a", encoding="utf-8") as f:
193
+ f.write(f"\nPATH=$PATH:{self.workspace_dir}\n")
194
+ print("Lembre-se de executar o comando abaixo:")
195
+ print(Color.g(f"source ~/{filename}"), "\n")
196
+
197
+ @staticmethod
198
+ def _prefixed_envs(prefix: str) -> dict[str, str]:
199
+ return {k[len(prefix) :].lower(): v for k, v in os.environ.items() if k.startswith(prefix)}
200
+
201
+ @property
202
+ def _verb(self) -> str:
203
+ return "exec" if self._service_is_running(self._service_name) else "run --rm"
204
+
205
+ @property
206
+ def _default_django(self) -> str:
207
+ django_svc = os.environ.get("DEFAULT_DJANGO", "web")
208
+ return django_svc if django_svc in self._services else "web"
209
+
210
+ def _resolve_django_service_and_args(self) -> tuple[str, str, list[str]]:
211
+ if self._service_name in self._services:
212
+ service, args = self._service_name, self.argv[3:]
213
+ else:
214
+ service, args = self._default_django, self.argv[2:]
215
+ verb = "exec" if self._service_is_running(service) else "run --rm"
216
+ return service, verb, args
217
+
218
+ def _write_hosts(self) -> None:
219
+ hosts = self._prefixed_envs("HOSTS")
220
+ if "" not in hosts or ":" not in hosts[""]:
221
+ return
222
+ hosts_name, hosts_value = hosts[""].split(":", 1)
223
+ hosts_file = WINDOWS_HOSTS if sys.platform == "win32" else POSIX_HOSTS
224
+ try:
225
+ content = hosts_file.read_text(encoding="utf-8")
226
+ except FileNotFoundError:
227
+ print(Color.y(f"Arquivo de hosts ({hosts_file}) não encontrado."))
228
+ return
229
+ if hosts_value in content:
230
+ return
231
+ print(f"Adicionando este {hosts_value} em {hosts_file}:")
232
+ nls = "#" * 70
233
+ new_hosts = f"\n{nls}\n## {hosts_name}\n{nls}\n{hosts_value}\n"
234
+ if sys.platform != "win32":
235
+ self._execute(f"echo '{new_hosts}' | sudo tee -a {hosts_file}")
236
+ return
237
+ try:
238
+ with hosts_file.open("a", encoding="utf-8") as f:
239
+ f.write(new_hosts)
240
+ except PermissionError:
241
+ print(
242
+ Color.r(
243
+ f"Permissão negada ao escrever em {hosts_file}. Execute o terminal como Administrador "
244
+ f"ou adicione manualmente a linha abaixo em {hosts_file}:\n{hosts_value}"
245
+ )
246
+ )
247
+
248
+ def _mk_volumes(self) -> None:
249
+ for mkvol in self.mk_volumes:
250
+ vol = self.volumes_dir / mkvol
251
+ print(vol)
252
+ vol.mkdir(parents=True, exist_ok=True)
253
+ if sys.platform != "win32":
254
+ self._execute(f"sudo chmod -R 777 {vol}")
255
+
256
+ # ------------------------------------------------------------------ comandos
257
+
258
+ def help(self) -> int:
259
+ """Imprime esta ajuda. Aplica-se a: env"""
260
+ print(f"""Uso:\n{Color.r(f'{self.project_name} [OPTIONS] COMMAND')}\n\nOptions:
261
+ {Color.m('--ansi')} {Color.y('string')} Control when to print ANSI control characters ("never"|"always"|"auto") (default "auto")
262
+ {Color.m('--compatibility')} Run compose in backward compatibility mode
263
+ {Color.m('--env-file')} {Color.y('string')} Specify an alternate environment file.
264
+ {Color.m('-f')}, {Color.m('--file')} {Color.y('stringArray')} Compose configuration files
265
+ {Color.m('--profile')} {Color.y('stringArray')} Specify a profile to enable
266
+ {Color.m('--project-directory')} {Color.y('string')} Specify an alternate working directory (default: the path of the, first specified, Compose file)
267
+ {Color.m('-p')}, {Color.m('--project-name')} {Color.y('string')} Project name\nComandos:""") # noqa: E501
268
+ for m in list(NATIVES.keys()) + list(self._commands.keys()):
269
+ fixed = Color.m(f"{m:22}")
270
+ description = self._descriptions.get(m, NATIVES.get(m, " Sem descrição"))
271
+ scopes = Color.c(", ".join(self._scopes.get(m, [" Ver `docker compose`"])))
272
+ print(f" {fixed} - {description}{scopes}")
273
+ print(f"Execute '{self.script.name} COMMAND help' para imprimir a ajuda.")
274
+ return 0
275
+
276
+ def relaunch(self) -> int:
277
+ """Para e depois inicia ambiente ou o serviço especificado, não faz lock do CLI. Aplica-se a: $services_names$,env""" # noqa: E501
278
+ self._execute(f"docker compose down {self._service_name}")
279
+ return self._execute(f"docker compose up -d {self._service_name}")
280
+
281
+ def launch(self) -> int:
282
+ """Mostra o status ambiente ou o serviço especificado. Aplica-se a: $services_names$,env"""
283
+ self.relaunch()
284
+ return self._execute(f"docker compose logs -f {self._service_name}")
285
+
286
+ def exec(self) -> int:
287
+ """Executa um comando em um serviço que já esteja em execução. Aplica-se a: $services_names$,env"""
288
+ return self._execute(f"docker compose {self._verb} {self._service_name} ", *self.argv[3:])
289
+
290
+ def clone(self) -> int:
291
+ """Clona todos os repositórios. Aplica-se a: env"""
292
+ for g in self.git_clones:
293
+ folder = Path(g.folder)
294
+ folder.parent.mkdir(mode=0o770, parents=True, exist_ok=True)
295
+ if not folder.exists():
296
+ print(f"Clonando {Color.c(folder)} do repositório {Color.y(g.url)} na branch {Color.m(g.branch)}")
297
+ self._execute(f'git clone -b {g.branch} {g.url} "{folder}"')
298
+ return 0
299
+
300
+ def setup(self) -> int:
301
+ """Executa o setup do ambiente, ou seja, clona todos os repositórios e configura o PATH e o hosts. Aplica-se a: env""" # noqa: E501
302
+ self._execute(f"docker compose down {self._service_name}")
303
+ self.clone()
304
+ self._write_hosts()
305
+ self._mk_volumes()
306
+ self._write_shell_file(".bashrc")
307
+ self._write_shell_file(".zshrc")
308
+ return 0
309
+
310
+ def deploy(self) -> int:
311
+ """Para tudo, constrói as imagens, iniciar os serviços background e acompanha o log. Aplica-se a: env"""
312
+ self.setup()
313
+ return self._execute("docker compose up -d")
314
+
315
+ def undeploy(self) -> int:
316
+ """Pára todo o ambiente e remove os volumes. Aplica-se a: $services_names$,env"""
317
+ return self._execute("docker compose down -v")
318
+
319
+ def git(self) -> int:
320
+ """Executa o comando git em todos os repositórios git. Aplica-se a: env"""
321
+ git_arguments = " ".join(self.argv[2:]) if len(self.argv) > 2 else "status"
322
+ print(f"Executando {Color.b('git ' + git_arguments)} em {Color.m(self.workspace_dir)}")
323
+ self._execute(f'git -C "{self.workspace_dir}" {git_arguments}')
324
+ for g in self.git_clones:
325
+ if Path(g.folder).exists():
326
+ print(f"Executando {Color.b('git ' + git_arguments)} em {Color.m(g.folder)}")
327
+ self._execute(f'git -C "{g.folder}" {git_arguments}')
328
+ else:
329
+ print(f"O projeto {g.folder} NÃO foi clonado ainda.")
330
+ return 0
331
+
332
+ def cli(self) -> int:
333
+ """Executa a CLI padrão do serviço, pode ser php -a, shell_plus, psql ou redis-cli. Bash por padrão. Usará exec se possível, caso contrário será run. Aplica-se a: $services_names$""" # noqa: E501
334
+ cmd = self.cli_dict.get(self._service_name, "bash")
335
+ return self._execute(f"docker compose {self._verb} {self._service_name} {cmd} ", *self.argv[3:])
336
+
337
+ def shell(self) -> int:
338
+ """Executa o bash ou sh de um serviço, usará exec se já estiver em execução, run caso contrário. Aplica-se a: $services_names$""" # noqa: E501
339
+ cmd = self.shell_dict.get(self._service_name, "bash")
340
+ return self._execute(f"docker compose {self._verb} {self._service_name} {cmd} ", *self.argv[3:])
341
+
342
+ def shell_root(self) -> int:
343
+ """Executa o bash ou sh de um serviço como root, usará exec se já estiver em execução, run caso contrário. Aplica-se a: $services_names$""" # noqa: E501
344
+ cmd = self.shell_dict.get(self._service_name, "bash")
345
+ return self._execute(f"docker compose {self._verb} -u root {self._service_name} {cmd} ", *self.argv[3:])
346
+
347
+ def wait(self) -> int:
348
+ """Aguarda até o banco de dados estar disponível. Aplica-se a: db"""
349
+ while not self._service_is_healthy("db"):
350
+ print(Color.y("Aguardando o banco de dados estar disponível..."))
351
+ time.sleep(3)
352
+ print(Color.g("Banco de dados disponível!"))
353
+ return 0
354
+
355
+ def manage(self) -> int:
356
+ """Executa o manage.py do do serviço. Aplica-se a: $services_names$"""
357
+ service, verb, args = self._resolve_django_service_and_args()
358
+ return self._execute(f"docker compose {verb} {service} python3 manage.py ", *args)
359
+
360
+ def makemigrations(self) -> int:
361
+ """Criar as migrações pendentes do serviço. Aplica-se a: $services_names$"""
362
+ service, verb, args = self._resolve_django_service_and_args()
363
+ return self._execute(f"docker compose {verb} {service} python3 manage.py makemigrations", *args)
364
+
365
+ def migrate(self) -> int:
366
+ """Aplica as migrações pendentes do serviço. Aplica-se a: $services_names$"""
367
+ service, verb, args = self._resolve_django_service_and_args()
368
+ return self._execute(f"docker compose {verb} {service} python3 manage.py migrate", *args)
369
+
370
+ def show_urls(self) -> int:
371
+ """Mostra as URL do serviço. Aplica-se a: $services_names$"""
372
+ service, verb, args = self._resolve_django_service_and_args()
373
+ return self._execute(f"docker compose {verb} {service} python manage.py show_urls", *args)
374
+
375
+ def test(self) -> int:
376
+ """Executa os testes unitários do serviço. Aplica-se a: $services_names$"""
377
+ service, verb, args = self._resolve_django_service_and_args()
378
+ self._execute(f"docker compose {verb} {service} coverage run manage.py test", *args)
379
+ return self._execute(f"docker compose {verb} {service} coverage report", *args)
380
+
381
+ def lint(self) -> int:
382
+ """Formata o código do serviço com black. Aplica-se a: $services_names$"""
383
+ service, verb, args = self._resolve_django_service_and_args()
384
+ return self._execute(f"docker compose {verb} {service} black --line-length 120 .", *args)
385
+
386
+ def compress_plugins(self) -> int:
387
+ """Comprime todos os plugins Moodle. Aplica-se a: env"""
388
+
389
+ def zip_with_prefix(folder_path: Path, zip_path: Path, prefix: str) -> None:
390
+ zip_path.parent.mkdir(parents=True, exist_ok=True)
391
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
392
+ for file_path in folder_path.rglob("*"):
393
+ if not file_path.is_file() or {".git", ".idea"} & set(file_path.parts):
394
+ continue
395
+ zipf.write(file_path, Path(prefix) / file_path.relative_to(folder_path))
396
+
397
+ build_dir = self.workspace_dir / "build" / "plugins"
398
+ for plugin_fullname in self.moodle_plugins:
399
+ plugin_name = plugin_fullname.split("_", 1)[1]
400
+ plugin_dir = self.project_dir / plugin_fullname
401
+ for line in (plugin_dir / "version.php").read_text(encoding="utf-8").splitlines():
402
+ if not line.startswith("$plugin->version"):
403
+ continue
404
+ plugin_version = line.split("=")[1].replace(";", "").split()[0]
405
+ plugin_package = build_dir / f"{plugin_fullname}-{plugin_version}.zip"
406
+ if plugin_package.exists():
407
+ print(f"Plugin {Color.c(plugin_package)} {Color.r('JÁ existe')}.")
408
+ continue
409
+ print(f"Plugin {Color.c(plugin_package)} {Color.g('NÃO existe')}.")
410
+ for old_zip in glob.glob(str(build_dir / f"{plugin_fullname}-*.zip")):
411
+ try:
412
+ os.remove(old_zip)
413
+ except OSError:
414
+ pass
415
+ zip_with_prefix(plugin_dir, plugin_package, plugin_name)
416
+ return 0
417
+
418
+ def publish(self) -> int:
419
+ """Comprime os plugins Moodle e constrói e publica a imagem do serviço ava. Aplica-se a: env"""
420
+ self.compress_plugins()
421
+ return self._execute("docker compose build --progress plain --push ava")
422
+
423
+ def code(self) -> int:
424
+ """Abre o workspace no VS Code. Aplica-se a: env"""
425
+ return self._execute(f'code "{self.workspace_dir / f"{self.project_name}.code-workspace"}"')
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: kcm-workspace
3
+ Version: 0.1.0
4
+ Summary: Base class for docker compose workspace scripts (clone, setup, launch, shell, manage and more)
5
+ Author-email: Kelson da Costa Medeiros <kelsoncm@gmail.com>
6
+ License: # License
7
+
8
+ ## The MIT License (MIT)
9
+
10
+ Copyright (c) 2026 python-by-kelsoncm
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Homepage, https://github.com/python-by-kelsoncm/python-kcm-workspace
31
+ Project-URL: Bug Tracker, https://github.com/python-by-kelsoncm/python-kcm-workspace/issues
32
+ Project-URL: Download, https://github.com/python-by-kelsoncm/python-kcm-workspace/releases/
33
+ Keywords: docker,compose,workspace,cli
34
+ Classifier: Development Status :: 4 - Beta
35
+ Classifier: Environment :: Console
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Operating System :: OS Independent
39
+ Classifier: Programming Language :: Python :: 3
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
44
+ Classifier: Programming Language :: Python :: 3.14
45
+ Requires-Python: >=3.10
46
+ Description-Content-Type: text/markdown
47
+ License-File: LICENSE.md
48
+ License-File: AUTHORS.md
49
+ Provides-Extra: dev
50
+ Requires-Dist: pre-commit>=4.6.0; extra == "dev"
51
+ Requires-Dist: black>=26.3.1; extra == "dev"
52
+ Requires-Dist: ruff>=0.15.11; extra == "dev"
53
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
54
+ Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
55
+ Dynamic: license-file
56
+
57
+ # kcm-workspace
58
+
59
+ Classe base para os scripts de workspace baseados em `docker compose`
60
+ (`clone`, `setup`, `launch`, `shell`, `manage`, `git` e outros).
61
+
62
+ Cada projeto tem, em `<projeto>/workspace/<projeto>`, um script que estende
63
+ `Workspace` e informa no construtor o que é específico dele.
64
+
65
+ ## Uso
66
+
67
+ ```python
68
+ #!/usr/bin/env -S uv run --script
69
+ # /// script
70
+ # requires-python = ">=3.10"
71
+ # dependencies = ["kcm-workspace>=0.1.0"]
72
+ # ///
73
+ from kcm_workspace import GitClone, Workspace
74
+
75
+ GITBASE = "git@github.com:minha-org"
76
+
77
+
78
+ class Projeto(Workspace):
79
+ def __init__(self):
80
+ super().__init__(
81
+ __file__,
82
+ git_clones=[GitClone("app", f"{GITBASE}/app.git")],
83
+ mk_volumes=["db"],
84
+ cli_dict={"db": "psql"},
85
+ shell_dict={"db": "sh"},
86
+ )
87
+
88
+ def collectstatic(self) -> int:
89
+ """Coleta os estáticos. Aplica-se a: $services_names$"""
90
+ return self._execute("docker compose exec web python manage.py collectstatic")
91
+
92
+
93
+ if __name__ == "__main__":
94
+ Projeto.main()
95
+ ```
96
+
97
+ - `GitClone.folder` relativo é resolvido a partir da pasta do projeto (pai de `workspace`).
98
+ - Um método vira comando quando a docstring termina com `Aplica-se a: <escopos>`.
99
+ - Comandos desconhecidos são repassados para `docker compose`.
100
+ - No Windows, use um wrapper `<projeto>.ps1`:
101
+
102
+ ```powershell
103
+ $nomeSemExtensao = (Get-Item $PSCommandPath).BaseName
104
+ uv run --script "$PSScriptRoot\$nomeSemExtensao" @args
105
+ ```
106
+
107
+ ## Atributos disponíveis
108
+
109
+ `project_name`, `project_dir`, `workspace_dir`, `volumes_dir`, `home`, `argv`,
110
+ `git_clones`, `mk_volumes`, `moodle_plugins`, `cli_dict`, `shell_dict`.
111
+
112
+ ## Métodos auxiliares
113
+
114
+ `_execute(cmd, *args)`, `_service_is_running(svc)`, `_service_is_healthy(svc)`,
115
+ `_prefixed_envs(prefix)`, `_resolve_django_service_and_args()`, `_verb`.
116
+
117
+ ## Desenvolvimento
118
+
119
+ ```bash
120
+ uv pip install -e ".[dev]"
121
+ pytest --cov=kcm_workspace
122
+ ```
@@ -0,0 +1,9 @@
1
+ kcm_workspace/__init__.py,sha256=ZY4WQywsOg6s0ZZXvSpzvdfToqoD2uKZnEchtl4sTMo,150
2
+ kcm_workspace/color.py,sha256=WdopDKsIe_6HZBaRuFXzzBT8D1y1CQrMgrO1TETNgDs,1176
3
+ kcm_workspace/workspace.py,sha256=8hwIY1auhfOlJhXKSD3xM6xK7KQhcky4oSUJCKjd2hs,20460
4
+ kcm_workspace-0.1.0.dist-info/licenses/AUTHORS.md,sha256=qk2yCTi5iFKzq5whkvvZ-Wq-Mj47xcoOJGJkbu_RnI0,84
5
+ kcm_workspace-0.1.0.dist-info/licenses/LICENSE.md,sha256=mYRSY4lBNy3XDQwvexDjx4MsqTkyIosr8nZF4aqQRIE,1099
6
+ kcm_workspace-0.1.0.dist-info/METADATA,sha256=ICt9N3jbWiv5qPKyahM4kwWj4FSRZmrbTyXcSi3QVf8,4631
7
+ kcm_workspace-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ kcm_workspace-0.1.0.dist-info/top_level.txt,sha256=UNEdZyrbKwAK2Bv8lJ8aRMNYREtGgoEduZxKdRxfrqw,14
9
+ kcm_workspace-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ # Authors
2
+
3
+ - Kelson da Costa Medeiros <kelsoncm@gmail.com>, Tech Leader & Developer
@@ -0,0 +1,23 @@
1
+ # License
2
+
3
+ ## The MIT License (MIT)
4
+
5
+ Copyright (c) 2026 python-by-kelsoncm
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ kcm_workspace