service-dev-env 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ SCRIPT_PATH="$(dirname "$0")"
3
+ BASE_PATH=$(realpath "$SCRIPT_PATH/..")
4
+ DOCKER_PATH=$(realpath "$1")
5
+ SERVICE="$2"
6
+
7
+ if [ $# -eq 2 ]; then
8
+ ENV_FILE=$(realpath $BASE_PATH/local_vars.env)
9
+ else
10
+ ENV_FILE=$3
11
+ fi
12
+
13
+ cd "$DOCKER_PATH" || exit 1
14
+
15
+ ENV_FILE=$ENV_FILE docker compose --env-file $ENV_FILE run --rm "$SERVICE" bash
16
+
17
+ echo "Press CTRL+C to close..."
18
+ sleep infinity
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ SCRIPT_PATH="$(dirname "$0")"
3
+ BASE_PATH=$(realpath "$SCRIPT_PATH/..")
4
+ DOCKER_PATH=$(realpath "$1")
5
+ SERVICE="$2"
6
+
7
+ if [ $# -eq 2 ]; then
8
+ ENV_FILE=$(realpath $BASE_PATH/local_vars.env)
9
+ else
10
+ ENV_FILE=$3
11
+ fi
12
+
13
+ emoji[0]="😖"
14
+ emoji[1]="😭"
15
+ emoji[2]="😫"
16
+ emoji[3]="😞"
17
+ emoji[4]="😓"
18
+
19
+
20
+ cd $DOCKER_PATH || exit 1
21
+
22
+ while :
23
+ do
24
+ ENV_FILE=$ENV_FILE docker compose --env-file $ENV_FILE logs -f --tail=50 $SERVICE
25
+
26
+ size=${#emoji[@]}
27
+ index=$(($RANDOM % $size))
28
+ echo "${emoji[$index]} $SERVICE log ended, press <CTRL+C> to close..."
29
+ sleep 5
30
+ done
31
+
32
+
33
+ ENV_FILE=$ENV_FILE docker compose --env-file $ENV_FILE logs -f $SERVICE
34
+
35
+ echo "Press CTRL+C to close..."
36
+ sleep infinity
@@ -0,0 +1,10 @@
1
+ #stats_box {
2
+ height: 3;
3
+ border: solid $accent;
4
+ background: $panel;
5
+ }
6
+
7
+ #table_box {
8
+ height: 1fr;
9
+ border: solid $accent;
10
+ }
service_dev_env/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ import argparse
2
+
3
+ from .docker_utils import DockerUtils
4
+ from .fun import goodbye
5
+ from .project import Project
6
+ from .terminals import TERMINALS
7
+ from .terminals.tilix import Tilix # default terminal
8
+ from .ui import DockerDevEnvUi
9
+
10
+
11
+ def _setup_argparse() -> argparse.ArgumentParser:
12
+ """Configure argument parser."""
13
+ parser = argparse.ArgumentParser(
14
+ description=__doc__,
15
+ formatter_class=argparse.RawTextHelpFormatter)
16
+ parser.add_argument(
17
+ '-c', '--cleanup', action='store_true',
18
+ help='just cleanup, do not start')
19
+ parser.add_argument(
20
+ '-n', '--no-cleanup', action='store_true',
21
+ help='no cleanup after finish')
22
+ parser.add_argument(
23
+ '-p', '--project', default='project-config.yml',
24
+ help='project yaml file')
25
+ parser.add_argument(
26
+ '-s', '--stopped', action='store_true',
27
+ help='do not create container, start with all container stopped')
28
+ parser.add_argument(
29
+ '-l', '--log',
30
+ help='only show log of <service name>')
31
+ parser.add_argument(
32
+ '--cmd', action='store_true',
33
+ help='only show commands to run the services for easy copy-and-paste,'
34
+ 'do not start.')
35
+ parser.add_argument(
36
+ '-e', '--env',
37
+ help='name of environment (defaults to default_env from your config)')
38
+ parser.add_argument(
39
+ '-t', '--terminal', default='tilix',
40
+ help='name of the terminal (defaults to tilix)')
41
+ parser.add_argument(
42
+ '-r', '--rebuild', action='store_true',
43
+ help='rebuild container before start')
44
+ parser.add_argument(
45
+ '-ro', '--rebuild-only', action='store_true',
46
+ help='rebuild container only, do not start')
47
+ parser.add_argument(
48
+ '-b', '--bash', default=None,
49
+ help='run bash inside the container')
50
+ parser.add_argument(
51
+ '-d', '--deploy', action='store_true',
52
+ help='build docker container for deploy environment and '
53
+ 'run deploy script')
54
+ # we show "-i"/init for --help but as its already handled
55
+ # by the ./run bash script we ignore it.
56
+ parser.add_argument(
57
+ '-i', '--init', action='store_true',
58
+ help='(re)init: deletes the local dev and recreates it.')
59
+ return parser
60
+
61
+
62
+ def main():
63
+ parser = _setup_argparse()
64
+ args = parser.parse_args()
65
+ project = Project(args.project)
66
+ if args.env:
67
+ project.select_env(args.env)
68
+ elif args.deploy:
69
+ if not project.select_deploy_env():
70
+ return
71
+ terminal = args.terminal
72
+ _terminal = TERMINALS[terminal] if terminal in TERMINALS else Tilix
73
+ _docker_utils = DockerUtils(project)
74
+ start = True
75
+ if args.cleanup:
76
+ _docker_utils.cleanup()
77
+ start = False
78
+ if args.rebuild or args.rebuild_only or args.deploy:
79
+ _docker_utils.build_all()
80
+ if args.rebuild_only:
81
+ start = False
82
+ if args.cmd:
83
+ _docker_utils.print_cmds()
84
+ start = False
85
+ if args.log:
86
+ _docker_utils.log(args.log)
87
+ # logs run forever, we don't want to continue
88
+ return
89
+ if args.bash:
90
+ _docker_utils.bash(args.bash)
91
+ # bash runs forever, we don't want to continue
92
+ return
93
+ if args.deploy:
94
+ project.deploy()
95
+ return
96
+ if not start:
97
+ return
98
+
99
+ if not args.stopped:
100
+ _docker_utils.autostart()
101
+ app = DockerDevEnvUi(_docker_utils, _terminal(project))
102
+ app.run()
103
+
104
+ # Cleanup at the end
105
+ if not args.no_cleanup:
106
+ _docker_utils.cleanup()
107
+ goodbye()
108
+
109
+
110
+ if __name__ == '__main__':
111
+ main()
@@ -0,0 +1,247 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from typing import Dict, List
6
+
7
+ import docker
8
+
9
+ from .model.project import ProjectConfig
10
+ from .model.service import ServiceConfig
11
+ from .runner import Runner
12
+
13
+
14
+ BASE_PATH = Path(__file__).absolute().parent
15
+
16
+
17
+ class DockerUtils(Runner):
18
+ """Wrapper around Popen and docker to manage docker container."""
19
+
20
+ def __init__(self, project: ProjectConfig):
21
+ super().__init__(project)
22
+ self.docker_client = docker.from_env()
23
+
24
+ def autostart(self):
25
+ if self.env.networks:
26
+ self.create_network(self.env.networks, )
27
+ for service in self.services:
28
+ if service.autostart:
29
+ self.compose_start(service)
30
+
31
+ def compose_cmd(self, cmd: List):
32
+ docker_cmd = ['docker', 'compose']
33
+ if self.env.docker_compose_path:
34
+ _path = self.env.docker_compose_path
35
+ if not Path(_path).exists():
36
+ raise RuntimeError(f'ERROR: {_path} does not exist.')
37
+ docker_cmd += ['-f', _path]
38
+ if self.env.env_file:
39
+ _path = self.env.env_path()
40
+ if not Path(_path).exists():
41
+ raise RuntimeError(f'ERROR: {_path} does not exist.')
42
+ docker_cmd += ['--env-file', _path]
43
+ if isinstance(cmd, str):
44
+ docker_cmd.append(cmd)
45
+ else:
46
+ docker_cmd += cmd
47
+ return docker_cmd
48
+
49
+ def compose_bash_cmd(self, service):
50
+ return self.compose_cmd(['run', '--rm', '-it', service.name, 'bash'])
51
+
52
+ def compose_logs_cmd(self, service):
53
+ return self.compose_cmd(['logs', '-f', '--tail=50', service.name])
54
+
55
+ def compose_start_cmd(self, service: ServiceConfig):
56
+ return self.compose_cmd(['up', '-d', service.name])
57
+
58
+ def _run_cmd(
59
+ self, cmd: List, service: ServiceConfig = None,
60
+ check: bool = False, env: Dict = None):
61
+ args = {'env': os.environ.copy()}
62
+ if env:
63
+ args['env'].update(env)
64
+ elif env is None:
65
+ args['env'].update(self._get_env())
66
+ if service and service.folder:
67
+ args['cwd'] = service.folder
68
+ args['check'] = check
69
+ try:
70
+ subprocess.run(cmd, **args)
71
+ except KeyboardInterrupt:
72
+ pass
73
+
74
+ def compose_start(self, service: ServiceConfig):
75
+ """Start docker compose command."""
76
+ if hasattr(service, 'description') and service.description:
77
+ print(f'🚀 Starting {service.description}...')
78
+ cmd = self.compose_start_cmd(service)
79
+ self._run_cmd(cmd, service)
80
+
81
+ def bash(self, service_name: str):
82
+ for service in self.services:
83
+ if service.name == service_name:
84
+ cmd = self.compose_bash_cmd(service)
85
+ self._run_cmd(cmd, service)
86
+ return
87
+ print(f'Error: unknown service {service_name}')
88
+
89
+ def log(self, service_name: str):
90
+ """Print out the log of the given service name."""
91
+ for service in self.services:
92
+ if service.name == service_name:
93
+ cmd = self.compose_logs_cmd(service)
94
+ self._run_cmd(cmd, service)
95
+ return
96
+ print(f'Error: unknown service {service_name}')
97
+
98
+ def print_cmds(self):
99
+ for env_name, env in self.project.env.items():
100
+ print(f'== {env_name} ==')
101
+ for service in env.services:
102
+ print(f'==== {service.name} ====')
103
+ print(' '.join(self.compose_build_cmd(service)))
104
+ print(' '.join(self.compose_start_cmd(service)))
105
+ print(' '.join(self.compose_logs_cmd(service)))
106
+ print(' '.join(self.compose_bash_cmd(service)))
107
+
108
+ def compose_kill_cmd(self, service: ServiceConfig):
109
+ """Kill container using compose (for faster down)."""
110
+ return self.compose_cmd(['kill', service.name])
111
+
112
+ def compose_kill(self, service: ServiceConfig):
113
+ """Kill container using compose (for faster down)."""
114
+ title = service.name
115
+ if hasattr(service, 'description') and service.description:
116
+ title = service.description
117
+ print(f'➜ Kill container {title}')
118
+ cmd = self.compose_kill_cmd(service)
119
+ self._run_cmd(cmd, service)
120
+
121
+ def compose_build_cmd(self, service: ServiceConfig):
122
+ """Build image by service name."""
123
+ return self.compose_cmd(['build', service.name])
124
+
125
+ def compose_build(self, service: ServiceConfig):
126
+ """Build image by service name."""
127
+ cmd = self.compose_build_cmd(service)
128
+ self._run_cmd(cmd, service)
129
+
130
+ def compose_down(self):
131
+ """Run docker compose down to clean up."""
132
+ cmd = self.compose_cmd(['down'])
133
+ self._run_cmd(cmd, check=False)
134
+
135
+ def create_network(self, networks):
136
+ if isinstance(networks, str):
137
+ networks = [networks]
138
+ for network in networks:
139
+ print(f'🌐 Create docker network {network}')
140
+ docker_cmd = ['docker', 'network', 'create', network]
141
+ subprocess.run(docker_cmd, check=False)
142
+
143
+ def rm_network(self, networks):
144
+ if isinstance(networks, str):
145
+ networks = [networks]
146
+ for network in networks:
147
+ print(f'➜ Remove network {network}')
148
+ docker_cmd = ['docker', 'network', 'rm', network]
149
+ subprocess.run(docker_cmd, check=False)
150
+
151
+ def _not_available(self, service: ServiceConfig):
152
+ return {
153
+ 'service': service.title,
154
+ 'container_id': 'N/A',
155
+ 'status': 'not running',
156
+ 'health': 'unknown',
157
+ 'image': 'N/A',
158
+ }
159
+
160
+ def _get_health_status(self, container) -> str:
161
+ """Get health check status from container."""
162
+ container.reload()
163
+ state = container.attrs.get('State', {})
164
+
165
+ if state.get('Health'):
166
+ health = state['Health'].get('Status', 'unknown')
167
+ if health == 'healthy':
168
+ color = 'green'
169
+ elif health == 'unhealthy':
170
+ color = 'red'
171
+ else:
172
+ color = 'yellow'
173
+ return f'[{color}]{health}[/]'
174
+ return 'no check'
175
+
176
+ def service_status(self, service: ServiceConfig):
177
+ containers = self.docker_client.containers.list(
178
+ all=True,
179
+ filters={'label': f'com.docker.compose.service={service.name}'}
180
+ )
181
+ if not containers:
182
+ return self._not_available(service)
183
+ container = containers[0]
184
+ health_status = self._get_health_status(container)
185
+ tags = container.image.tags
186
+ return {
187
+ 'service': service.title,
188
+ 'container_id': container.short_id,
189
+ 'status': container.status,
190
+ 'health': health_status,
191
+ 'image': tags[0] if tags else 'unknown',
192
+ }
193
+
194
+ def service_running(self, service: ServiceConfig):
195
+ containers = self.docker_client.containers.list(
196
+ all=True,
197
+ filters={'label': f'com.docker.compose.service={service.name}'}
198
+ )
199
+ if not containers:
200
+ return False
201
+ container = containers[0]
202
+ return container.status == 'running'
203
+
204
+ def health_row(self, service: ServiceConfig):
205
+ service_status = self.service_status(service)
206
+ return [
207
+ service_status['service'],
208
+ service_status['container_id'],
209
+ service_status['status'],
210
+ service_status['health'],
211
+ service_status['image'],
212
+ ]
213
+
214
+ def rebuild(self, app, service: ServiceConfig):
215
+ async def _rebuild_done():
216
+ app.notify(f'Rebuild for {service.name} done.')
217
+ if service.autostart:
218
+ self.run_background(
219
+ service.folder, [self.compose_start_cmd(service)], app)
220
+ cmds = [
221
+ self.compose_kill_cmd(service),
222
+ self.compose_build_cmd(service)]
223
+ self.run_background(service.folder, cmds, app, _rebuild_done)
224
+
225
+ def restart(self, app, service: ServiceConfig):
226
+ cmds = []
227
+ if self.service_running(service):
228
+ cmds.append(self.compose_kill_cmd(service))
229
+ cmds.append(self.compose_start_cmd(service))
230
+ self.run_background(service.folder, cmds, app)
231
+
232
+ def kill(self, app, service: ServiceConfig):
233
+ self.run_background(
234
+ service.folder, [self.compose_kill_cmd(service)], app)
235
+
236
+ def build_all(self):
237
+ for service in self.services:
238
+ if service.rebuild:
239
+ self.compose_build(service)
240
+
241
+ def cleanup(self):
242
+ for service in self.services:
243
+ self.compose_kill(service)
244
+ self.compose_down()
245
+ if self.env.networks and self.env.cleanup_networks:
246
+ self.rm_network(self.env.networks)
247
+ print(' 🧽 Cleanup complete.')
service_dev_env/fun.py ADDED
@@ -0,0 +1,18 @@
1
+ import random
2
+
3
+ FAREWELL = [
4
+ 'How lucky I am to have something that makes saying goodbye so hard.',
5
+ "Don't cry because it's over, smile because it happened.",
6
+ 'The pain of parting is nothing to the joy of meeting again.',
7
+ "Man's feelings are always purest and most glowing in the "
8
+ 'hour of meeting and farewell.',
9
+ 'So long, and thanks for all the fish.',
10
+ 'We only part to meet again.',
11
+ 'Every goodbye makes the next hello closer.',
12
+ 'Let there be spaces in your togetherness.',
13
+ 'My battery is low, and its getting dark.'
14
+ ]
15
+
16
+
17
+ def goodbye():
18
+ print(f'\n 👋 Goodbye: {random.choice(FAREWELL)} ')
@@ -0,0 +1,33 @@
1
+ import math
2
+
3
+
4
+ class LayoutCalculator:
5
+ """Calculates optimal layout dimensions."""
6
+
7
+ # Strategy pattern: custom layout functions
8
+ _custom_strategies: dict[int, tuple[int, int]] = {}
9
+
10
+ @classmethod
11
+ def register_strategy(cls, num_windows: int, rows: int, cols: int) -> None:
12
+ """Register a custom layout strategy."""
13
+ cls._custom_strategies[num_windows] = (rows, cols)
14
+
15
+ @classmethod
16
+ def calculate(
17
+ cls,
18
+ num_windows: int,
19
+ prefer_horizontal: bool = False,
20
+ ) -> tuple[int, int]:
21
+ """Calculate optimal rows and columns."""
22
+ # Check custom strategies first
23
+ if num_windows in cls._custom_strategies:
24
+ return cls._custom_strategies[num_windows]
25
+
26
+ if 12 > num_windows > 17:
27
+ # this would end up in a 3-5 window, we prefer 4x4 instead
28
+ return 4, 4
29
+ else:
30
+ # heuristic to calculate square-ish terminal alignment
31
+ cols = int(math.sqrt(num_windows))
32
+ rows = (num_windows + cols - 1) // cols
33
+ return (cols, rows) if prefer_horizontal else (rows, cols)
File without changes
@@ -0,0 +1,42 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from .publish import PublishConfig
5
+ from .service import ServiceConfig
6
+
7
+
8
+ @dataclass
9
+ class EnvConfig:
10
+ """Defined environment with multiple services and its own compose file."""
11
+
12
+ services: list[ServiceConfig]
13
+ docker_compose_path: str | None = None
14
+ update_interval: int = 1
15
+ log_tail: int = 100
16
+ build_timeout: float = 3600
17
+ env_file: str | None = None
18
+ networks: list | None = None
19
+ cleanup_networks: bool | None = True
20
+ publish: PublishConfig | None = None
21
+ deploy_script: str | None = None
22
+ folder: str = '.'
23
+
24
+ def __post_init__(self):
25
+ services = []
26
+ if self.services:
27
+ for _service in self.services:
28
+ if isinstance(_service, ServiceConfig):
29
+ services.append(_service)
30
+ else:
31
+ services.append(ServiceConfig(**_service))
32
+ self.services = services
33
+ if self.publish:
34
+ if not isinstance(self.publish, PublishConfig):
35
+ self.publish = PublishConfig(**self.publish)
36
+
37
+ def env_path(self):
38
+ _env = self.env_file or ''
39
+ if _env.startswith('/'):
40
+ return _env
41
+ path = Path('.').absolute()
42
+ return str(path / _env)
@@ -0,0 +1,21 @@
1
+ """Layout mode and configuration models."""
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+
6
+ class LayoutMode(Enum):
7
+ """Provide layout configuration modes."""
8
+
9
+ AUTO = 'auto' # Smart layout based on window count
10
+ GRID = 'grid' # Manual rows x columns
11
+ CUSTOM = 'custom' # Manual window specification
12
+
13
+
14
+ @dataclass
15
+ class LayoutConfig:
16
+ """Layout configuration."""
17
+
18
+ mode: LayoutMode
19
+ rows: int | None = None
20
+ cols: int | None = None
21
+ prefer_horizontal: bool = False # Favor vertical alginment if True
@@ -0,0 +1,23 @@
1
+ """Project definition."""
2
+ from dataclasses import dataclass, field
3
+
4
+ from .terminal import TerminalConfig
5
+ from .env import EnvConfig
6
+
7
+
8
+ @dataclass
9
+ class ProjectConfig:
10
+ name: str = 'new project'
11
+ default_env: str = 'dev'
12
+ deploy_env: str = 'deploy'
13
+ env: dict[str, EnvConfig] = field(default_factory=dict)
14
+ terminal: TerminalConfig = field(default_factory=TerminalConfig)
15
+
16
+ def __post_init__(self):
17
+ _envs = {}
18
+ for env_name, env_data in self.env.items():
19
+ if isinstance(env_data, EnvConfig):
20
+ _envs[env_name] = env_data
21
+ else:
22
+ _envs[env_name] = EnvConfig(**env_data)
23
+ self.env = _envs
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class PublishConfig:
6
+ """Configuration for Server deployment."""
7
+
8
+ # rebuild before publishing
9
+ build: bool = True
10
+ # script to run after publishing is done
11
+ publish_script: str = ''
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class ServiceConfig:
6
+ # unique name, has to match docker service name in compose file
7
+ name: str
8
+ # (optional) description of the service
9
+ description: str | None = None
10
+ rebuild: bool = True # set to false if you have just a base image
11
+ # do not start the service when calling service-dev-env
12
+ autostart: bool = True
13
+ folder: str | None = None
14
+ open_url: str | None = None
15
+
16
+ # allow terminal windows for log/bash
17
+ terminal_log: bool = True
18
+ terminal_bash: bool = True
19
+
20
+ @property
21
+ def title(self):
22
+ """Return the title we show to the user."""
23
+ return self.description if self.description else self.name
@@ -0,0 +1,12 @@
1
+ """Terminal configuration models."""
2
+ from dataclasses import dataclass, field
3
+ from .layout import LayoutMode, LayoutConfig
4
+
5
+
6
+ @dataclass
7
+ class TerminalConfig:
8
+ """Base terminal configuration."""
9
+
10
+ terminal_type: str = 'tilix'
11
+ layout_config: LayoutConfig = field(
12
+ default_factory=lambda: LayoutConfig(mode=LayoutMode.AUTO))
@@ -0,0 +1,17 @@
1
+ """Window specification models."""
2
+ from dataclasses import dataclass, field
3
+ from .service import ServiceConfig
4
+
5
+
6
+ @dataclass
7
+ class WindowConfig:
8
+ """Specification for a single terminal window."""
9
+
10
+ service_name: str
11
+ width_fraction: float = 1.0
12
+ height_fraction: float = 1.0
13
+ x_position: int = 0
14
+ y_position: int = 0
15
+ profile: str | None = None
16
+ extra_config: dict = field(default_factory=dict)
17
+ service: ServiceConfig | None = None