kcm-workspace 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.
@@ -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,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,66 @@
1
+ # kcm-workspace
2
+
3
+ Classe base para os scripts de workspace baseados em `docker compose`
4
+ (`clone`, `setup`, `launch`, `shell`, `manage`, `git` e outros).
5
+
6
+ Cada projeto tem, em `<projeto>/workspace/<projeto>`, um script que estende
7
+ `Workspace` e informa no construtor o que é específico dele.
8
+
9
+ ## Uso
10
+
11
+ ```python
12
+ #!/usr/bin/env -S uv run --script
13
+ # /// script
14
+ # requires-python = ">=3.10"
15
+ # dependencies = ["kcm-workspace>=0.1.0"]
16
+ # ///
17
+ from kcm_workspace import GitClone, Workspace
18
+
19
+ GITBASE = "git@github.com:minha-org"
20
+
21
+
22
+ class Projeto(Workspace):
23
+ def __init__(self):
24
+ super().__init__(
25
+ __file__,
26
+ git_clones=[GitClone("app", f"{GITBASE}/app.git")],
27
+ mk_volumes=["db"],
28
+ cli_dict={"db": "psql"},
29
+ shell_dict={"db": "sh"},
30
+ )
31
+
32
+ def collectstatic(self) -> int:
33
+ """Coleta os estáticos. Aplica-se a: $services_names$"""
34
+ return self._execute("docker compose exec web python manage.py collectstatic")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ Projeto.main()
39
+ ```
40
+
41
+ - `GitClone.folder` relativo é resolvido a partir da pasta do projeto (pai de `workspace`).
42
+ - Um método vira comando quando a docstring termina com `Aplica-se a: <escopos>`.
43
+ - Comandos desconhecidos são repassados para `docker compose`.
44
+ - No Windows, use um wrapper `<projeto>.ps1`:
45
+
46
+ ```powershell
47
+ $nomeSemExtensao = (Get-Item $PSCommandPath).BaseName
48
+ uv run --script "$PSScriptRoot\$nomeSemExtensao" @args
49
+ ```
50
+
51
+ ## Atributos disponíveis
52
+
53
+ `project_name`, `project_dir`, `workspace_dir`, `volumes_dir`, `home`, `argv`,
54
+ `git_clones`, `mk_volumes`, `moodle_plugins`, `cli_dict`, `shell_dict`.
55
+
56
+ ## Métodos auxiliares
57
+
58
+ `_execute(cmd, *args)`, `_service_is_running(svc)`, `_service_is_healthy(svc)`,
59
+ `_prefixed_envs(prefix)`, `_resolve_django_service_and_args()`, `_verb`.
60
+
61
+ ## Desenvolvimento
62
+
63
+ ```bash
64
+ uv pip install -e ".[dev]"
65
+ pytest --cov=kcm_workspace
66
+ ```
@@ -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"
@@ -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,13 @@
1
+ AUTHORS.md
2
+ LICENSE.md
3
+ README.md
4
+ pyproject.toml
5
+ kcm_workspace/__init__.py
6
+ kcm_workspace/color.py
7
+ kcm_workspace/workspace.py
8
+ kcm_workspace.egg-info/PKG-INFO
9
+ kcm_workspace.egg-info/SOURCES.txt
10
+ kcm_workspace.egg-info/dependency_links.txt
11
+ kcm_workspace.egg-info/requires.txt
12
+ kcm_workspace.egg-info/top_level.txt
13
+ tests/test_workspace.py
@@ -0,0 +1,7 @@
1
+
2
+ [dev]
3
+ pre-commit>=4.6.0
4
+ black>=26.3.1
5
+ ruff>=0.15.11
6
+ pytest>=9.0.3
7
+ pytest-cov>=7.1.0
@@ -0,0 +1 @@
1
+ kcm_workspace
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kcm-workspace"
7
+ version = "0.1.0"
8
+ description = "Base class for docker compose workspace scripts (clone, setup, launch, shell, manage and more)"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE.md" }
11
+ authors = [{ name = "Kelson da Costa Medeiros", email = "kelsoncm@gmail.com" }]
12
+ keywords = ["docker", "compose", "workspace", "cli"]
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pre-commit>=4.6.0",
31
+ "black>=26.3.1",
32
+ "ruff>=0.15.11",
33
+ "pytest>=9.0.3",
34
+ "pytest-cov>=7.1.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/python-by-kelsoncm/python-kcm-workspace"
39
+ "Bug Tracker" = "https://github.com/python-by-kelsoncm/python-kcm-workspace/issues"
40
+ Download = "https://github.com/python-by-kelsoncm/python-kcm-workspace/releases/"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["."]
44
+ include = ["kcm_workspace*"]
45
+
46
+ [tool.coverage.run]
47
+ source = ["kcm_workspace"]
48
+ omit = ["tests/*"]
49
+
50
+ [tool.coverage.report]
51
+ fail_under = 90
52
+ show_missing = true
53
+
54
+ [tool.ruff]
55
+ line-length = 120
56
+ force-exclude = true
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "W", "I", "S"]
60
+
61
+ [tool.ruff.lint.per-file-ignores]
62
+ "tests/*" = ["S101", "S108"]
63
+ "kcm_workspace/workspace.py" = ["S602", "S603", "S607"]
64
+
65
+ [tool.black]
66
+ line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,264 @@
1
+ import os
2
+ import subprocess
3
+ import zipfile
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ import kcm_workspace.workspace as ws_module
10
+ from kcm_workspace import Color, GitClone, Workspace
11
+
12
+ COMPOSE_CONFIG = """name: demo
13
+ services:
14
+ db:
15
+ image: postgres:16
16
+ web:
17
+ image: ghcr.io/org/web:latest
18
+ networks:
19
+ default:
20
+ name: demo_default
21
+ """
22
+
23
+
24
+ class Demo(Workspace):
25
+ def __init__(self, script, argv, **kwargs):
26
+ super().__init__(script, argv=argv, **kwargs)
27
+
28
+ def extra(self) -> int:
29
+ """Comando do projeto. Aplica-se a: $services_names$,env"""
30
+ return self._execute("echo", "extra")
31
+
32
+ def not_a_command(self):
33
+ """Sem escopo."""
34
+
35
+
36
+ @pytest.fixture
37
+ def project(tmp_path: Path) -> Path:
38
+ workspace = tmp_path / "demo" / "workspace"
39
+ workspace.mkdir(parents=True)
40
+ script = workspace / "demo"
41
+ script.write_text("#!/usr/bin/env python3\n")
42
+ return script
43
+
44
+
45
+ @pytest.fixture
46
+ def calls(monkeypatch):
47
+ """Captura comandos executados e simula docker."""
48
+
49
+ class Executed(list):
50
+ containers = {"value": ""}
51
+
52
+ executed = Executed()
53
+ containers = executed.containers
54
+
55
+ def fake_run(cmd, **kwargs):
56
+ if cmd[:3] == ["docker", "compose", "config"]:
57
+ return SimpleNamespace(stdout=COMPOSE_CONFIG, returncode=0)
58
+ if cmd[:3] == ["docker", "container", "ps"]:
59
+ return SimpleNamespace(stdout=containers["value"], returncode=0)
60
+ raise AssertionError(cmd)
61
+
62
+ class FakePopen:
63
+ def __init__(self, command, **kwargs):
64
+ executed.append(command)
65
+
66
+ def wait(self):
67
+ return 0
68
+
69
+ monkeypatch.setattr(subprocess, "run", fake_run)
70
+ monkeypatch.setattr(subprocess, "Popen", FakePopen)
71
+ monkeypatch.chdir(os.getcwd())
72
+ return executed
73
+
74
+
75
+ def make(project: Path, *args: str, **kwargs) -> Demo:
76
+ return Demo(project, ["demo", *args], **kwargs)
77
+
78
+
79
+ def test_paths(project: Path, monkeypatch):
80
+ monkeypatch.delenv("VOLUMES_DIR", raising=False)
81
+ w = make(project, git_clones=[GitClone("app", "url"), GitClone(project.parent.parent / "abs", "u2", "dev")])
82
+ assert w.project_name == "demo"
83
+ assert w.workspace_dir == project.parent
84
+ assert w.project_dir == project.parent.parent
85
+ assert w.volumes_dir == project.parent.parent / "volumes"
86
+ assert w.git_clones[0].folder == project.parent.parent / "app"
87
+ assert w.git_clones[1].folder == project.parent.parent / "abs"
88
+ assert w.git_clones[1].branch == "dev"
89
+
90
+
91
+ def test_help_without_args(project, calls, capsys):
92
+ assert make(project).run() == 0
93
+ out = capsys.readouterr().out
94
+ assert "extra" in out and "relaunch" in out and "not_a_command" not in out
95
+ assert "db,web,env" in out.replace(Color.ENDC, "").replace("\033[96m", "").replace(", ", ",")
96
+
97
+
98
+ def test_command_with_service(project, calls):
99
+ assert make(project, "extra", "web").run() == 0
100
+ assert calls == ["echo extra"]
101
+
102
+
103
+ def test_passthrough_to_compose(project, calls):
104
+ make(project, "ps", "-a").run()
105
+ assert calls == ["docker compose ps -a"]
106
+
107
+
108
+ def test_exec_uses_run_when_not_running(project, calls):
109
+ make(project, "exec", "web", "ls").run()
110
+ assert calls == ["docker compose run --rm web ls"]
111
+
112
+
113
+ def test_exec_uses_exec_when_running(project, calls):
114
+ calls.containers["value"] = "abc demo-web-1 Up"
115
+ make(project, "exec", "web", "ls").run()
116
+ assert calls == ["docker compose exec web ls"]
117
+
118
+
119
+ def test_cli_and_shell(project, calls):
120
+ make(project, "cli", "db", cli_dict={"db": "psql"}).run()
121
+ make(project, "shell", "db", shell_dict={"db": "sh"}).run()
122
+ make(project, "shell_root", "web").run()
123
+ assert calls == [
124
+ "docker compose run --rm db psql ",
125
+ "docker compose run --rm db sh ",
126
+ "docker compose run --rm -u root web bash ",
127
+ ]
128
+
129
+
130
+ def test_django_commands(project, calls, monkeypatch):
131
+ monkeypatch.delenv("DEFAULT_DJANGO", raising=False)
132
+ for cmd in ("manage", "makemigrations", "migrate", "show_urls", "test", "lint"):
133
+ make(project, cmd).run()
134
+ make(project, "migrate", "db", "app").run()
135
+ assert calls[0] == "docker compose run --rm web python3 manage.py "
136
+ assert calls[2] == "docker compose run --rm web python3 manage.py migrate"
137
+ assert "coverage report" in calls[5]
138
+ assert calls[-1] == "docker compose run --rm db python3 manage.py migrate app"
139
+
140
+
141
+ def test_relaunch_launch_deploy_undeploy(project, calls, monkeypatch):
142
+ monkeypatch.setattr(ws_module, "HOME", project.parent)
143
+ make(project, "launch", "web").run()
144
+ make(project, "undeploy").run()
145
+ assert calls[:3] == ["docker compose down web", "docker compose up -d web", "docker compose logs -f web"]
146
+ assert calls[3] == "docker compose down -v"
147
+
148
+
149
+ def test_setup(project, calls, monkeypatch, tmp_path):
150
+ home = tmp_path / "home"
151
+ home.mkdir()
152
+ (home / ".bashrc").write_text("# vazio\n")
153
+ hosts = tmp_path / "hosts"
154
+ hosts.write_text("127.0.0.1 localhost\n")
155
+ monkeypatch.setattr(ws_module, "WINDOWS_HOSTS", hosts)
156
+ monkeypatch.setattr(ws_module, "POSIX_HOSTS", hosts)
157
+ monkeypatch.setattr(ws_module.sys, "platform", "win32")
158
+ monkeypatch.setenv("HOSTS", "demo:127.0.0.1 demo.local")
159
+ monkeypatch.setenv("VOLUMES_DIR", str(tmp_path / "vols"))
160
+ w = make(project, "setup", git_clones=[GitClone("app", "git@x:app.git")], mk_volumes=["db"])
161
+ w.home = home
162
+ assert w.run() == 0
163
+ assert any(c.startswith("git clone -b main git@x:app.git") for c in calls)
164
+ assert "127.0.0.1 demo.local" in hosts.read_text()
165
+ assert (tmp_path / "vols" / "db").is_dir()
166
+ assert str(project.parent) in (home / ".bashrc").read_text()
167
+
168
+
169
+ def test_write_hosts_posix_and_missing(project, calls, monkeypatch, tmp_path):
170
+ hosts = tmp_path / "hosts"
171
+ hosts.write_text("")
172
+ monkeypatch.setattr(ws_module, "POSIX_HOSTS", hosts)
173
+ monkeypatch.setattr(ws_module.sys, "platform", "linux")
174
+ monkeypatch.setenv("HOSTS", "demo:127.0.0.1 demo.local")
175
+ w = make(project)
176
+ w._write_hosts()
177
+ assert "sudo tee -a" in calls[-1]
178
+ monkeypatch.setattr(ws_module, "POSIX_HOSTS", tmp_path / "nao-existe")
179
+ w._write_hosts()
180
+ w.mk_volumes = ["x"]
181
+ w.volumes_dir = tmp_path / "v"
182
+ w._mk_volumes()
183
+ assert calls[-1].startswith("sudo chmod -R 777")
184
+
185
+
186
+ def test_write_hosts_permission_denied(project, calls, monkeypatch, tmp_path, capsys):
187
+ hosts = tmp_path / "hosts"
188
+ hosts.write_text("")
189
+ monkeypatch.setattr(ws_module, "WINDOWS_HOSTS", hosts)
190
+ monkeypatch.setattr(ws_module.sys, "platform", "win32")
191
+ monkeypatch.setenv("HOSTS", "demo:127.0.0.1 demo.local")
192
+
193
+ original_open = Path.open
194
+
195
+ def deny(self, mode="r", *args, **kwargs):
196
+ if "a" in mode:
197
+ raise PermissionError
198
+ return original_open(self, mode, *args, **kwargs)
199
+
200
+ monkeypatch.setattr(Path, "open", deny)
201
+ make(project)._write_hosts()
202
+ assert "Permissão negada" in capsys.readouterr().out
203
+
204
+
205
+ def test_read_envs(project, calls):
206
+ (project.parent / ".env").write_text("# comentário\n\nKCM_TEST_VAR=a=b\nCOMPOSE_PROJECT_NAME=demo\n")
207
+ make(project, "ps").run()
208
+ assert os.environ["KCM_TEST_VAR"] == "a=b"
209
+ (project.parent / ".env").write_text("COMPOSE_PROJECT_NAME=outro\n")
210
+ with pytest.raises(ValueError, match="devia ser COMPOSE_PROJECT_NAME=demo"):
211
+ make(project, "ps").run()
212
+
213
+
214
+ def test_git(project, calls, tmp_path):
215
+ (project.parent.parent / "app").mkdir()
216
+ make(project, "git", "pull", git_clones=[GitClone("app", "u"), GitClone("falta", "u")]).run()
217
+ assert calls[0].endswith("pull") and len(calls) == 2
218
+
219
+
220
+ def test_wait(project, calls, monkeypatch):
221
+ states = iter(["", "x demo-db-1 Up (healthy)"])
222
+ monkeypatch.setattr(Workspace, "_container_lines", lambda self: next(states).splitlines())
223
+ monkeypatch.setattr(ws_module.time, "sleep", lambda s: None)
224
+ assert make(project, "wait", "db").run() == 0
225
+
226
+
227
+ def test_compress_and_publish(project, calls):
228
+ plugin = project.parent.parent / "local_demo"
229
+ (plugin / ".git").mkdir(parents=True)
230
+ (plugin / ".git" / "HEAD").write_text("x")
231
+ (plugin / "version.php").write_text("<?php\n$plugin->version = 2026102800;\n")
232
+ build = project.parent / "build" / "plugins"
233
+ build.mkdir(parents=True)
234
+ (build / "local_demo-1.zip").write_text("velho")
235
+ make(project, "publish", moodle_plugins=["local_demo"]).run()
236
+ package = build / "local_demo-2026102800.zip"
237
+ assert package.exists() and not (build / "local_demo-1.zip").exists()
238
+ with zipfile.ZipFile(package) as z:
239
+ assert z.namelist() == ["demo/version.php"]
240
+ make(project, "compress_plugins", moodle_plugins=["local_demo"]).run()
241
+ assert calls[-1] == "docker compose build --progress plain --push ava"
242
+
243
+
244
+ def test_code(project, calls):
245
+ make(project, "code").run()
246
+ assert calls[0].startswith("code ") and calls[0].endswith('demo.code-workspace"')
247
+
248
+
249
+ def test_keyboard_interrupt(project, calls, monkeypatch):
250
+ def boom(self):
251
+ raise KeyboardInterrupt
252
+
253
+ monkeypatch.setattr(Demo, "_read_envs", boom)
254
+ assert make(project, "ps").run() == 130
255
+
256
+
257
+ def test_main_exits_with_code(project, calls, monkeypatch):
258
+ class Main(Workspace):
259
+ def __init__(self):
260
+ super().__init__(project, argv=["demo", "ps"])
261
+
262
+ with pytest.raises(SystemExit) as exc:
263
+ Main.main()
264
+ assert exc.value.code == 0