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.
- kcm_workspace-0.1.0/AUTHORS.md +3 -0
- kcm_workspace-0.1.0/LICENSE.md +23 -0
- kcm_workspace-0.1.0/PKG-INFO +122 -0
- kcm_workspace-0.1.0/README.md +66 -0
- kcm_workspace-0.1.0/kcm_workspace/__init__.py +5 -0
- kcm_workspace-0.1.0/kcm_workspace/color.py +45 -0
- kcm_workspace-0.1.0/kcm_workspace/workspace.py +425 -0
- kcm_workspace-0.1.0/kcm_workspace.egg-info/PKG-INFO +122 -0
- kcm_workspace-0.1.0/kcm_workspace.egg-info/SOURCES.txt +13 -0
- kcm_workspace-0.1.0/kcm_workspace.egg-info/dependency_links.txt +1 -0
- kcm_workspace-0.1.0/kcm_workspace.egg-info/requires.txt +7 -0
- kcm_workspace-0.1.0/kcm_workspace.egg-info/top_level.txt +1 -0
- kcm_workspace-0.1.0/pyproject.toml +66 -0
- kcm_workspace-0.1.0/setup.cfg +4 -0
- kcm_workspace-0.1.0/tests/test_workspace.py +264 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|