servly 0.1.0__py3-none-any.whl → 0.2.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.
servly/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Servly - A simple process management tool.
3
+ """
4
+
5
+ __version__ = "0.1.0"
servly/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for Servly process manager.
4
+ """
5
+ import os
6
+ import sys
7
+ import argparse
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from servly.service import ServiceManager
13
+ from servly.logs import LogManager
14
+ from servly.logs import Colors, Emojis # 导入颜色和表情符号定义
15
+ from servly.logs import align_colored_text # 导入对齐工具函数
16
+
17
+ # 配置彩色日志记录器
18
+ class ColoredFormatter(logging.Formatter):
19
+ """添加颜色支持的日志格式化器"""
20
+
21
+ def format(self, record):
22
+ log_message = super().format(record)
23
+
24
+ if record.levelno >= logging.ERROR:
25
+ return f"{Emojis.ERROR} {Colors.BRIGHT_RED}{log_message}{Colors.RESET}"
26
+ elif record.levelno >= logging.WARNING:
27
+ return f"{Emojis.WARNING} {Colors.YELLOW}{log_message}{Colors.RESET}"
28
+ elif record.levelno >= logging.INFO:
29
+ return f"{Emojis.INFO} {Colors.GREEN}{log_message}{Colors.RESET}"
30
+ else:
31
+ return f"{Colors.BRIGHT_BLACK}{log_message}{Colors.RESET}"
32
+
33
+ # 配置日志
34
+ handler = logging.StreamHandler()
35
+ handler.setFormatter(ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
36
+ logger = logging.getLogger('servly')
37
+ logger.setLevel(logging.INFO)
38
+ logger.addHandler(handler)
39
+
40
+ def print_header():
41
+ """打印美化的 Servly 头部"""
42
+ print(f"\n{Colors.CYAN}{Colors.BOLD}{'=' * 50}")
43
+ print(f"{Emojis.SERVICE} SERVLY {Colors.BRIGHT_BLACK}- Modern Process Manager")
44
+ print(f"{Colors.CYAN}{'=' * 50}{Colors.RESET}\n")
45
+
46
+ def setup_arg_parser() -> argparse.ArgumentParser:
47
+ """设置命令行参数解析器"""
48
+ parser = argparse.ArgumentParser(
49
+ description=f"{Colors.CYAN}{Colors.BOLD}{Emojis.SERVICE} Servly - Modern process manager{Colors.RESET}",
50
+ formatter_class=argparse.RawDescriptionHelpFormatter
51
+ )
52
+
53
+ # 全局参数
54
+ parser.add_argument('-f', '--config',
55
+ help='配置文件路径 (默认: servly.yml)',
56
+ default='servly.yml')
57
+
58
+ subparsers = parser.add_subparsers(dest='command', help='要执行的命令')
59
+
60
+ # Start 命令
61
+ start_parser = subparsers.add_parser('start', help=f'{Emojis.START} 启动服务')
62
+ start_parser.add_argument('service', nargs='?', default='all',
63
+ help='服务名称或 "all" 启动所有服务')
64
+
65
+ # Stop 命令
66
+ stop_parser = subparsers.add_parser('stop', help=f'{Emojis.STOP} 停止服务')
67
+ stop_parser.add_argument('service', nargs='?', default='all',
68
+ help='服务名称或 "all" 停止所有服务')
69
+
70
+ # Restart 命令
71
+ restart_parser = subparsers.add_parser('restart', help=f'{Emojis.RESTART} 重启服务')
72
+ restart_parser.add_argument('service', nargs='?', default='all',
73
+ help='服务名称或 "all" 重启所有服务')
74
+
75
+ # Log 命令
76
+ log_parser = subparsers.add_parser('log', help=f'{Emojis.LOG} 查看服务日志')
77
+ log_parser.add_argument('service', nargs='?', default='all',
78
+ help='服务名称或 "all" 查看所有日志')
79
+ log_parser.add_argument('--no-follow', '-n', action='store_true',
80
+ help='不实时跟踪日志')
81
+ log_parser.add_argument('--lines', '-l', type=int, default=10,
82
+ help='初始显示的行数')
83
+
84
+ # List 命令 - 保留原命令名,不改为 ps
85
+ list_parser = subparsers.add_parser('list', help='列出服务')
86
+
87
+ return parser
88
+
89
+ def handle_start(manager: ServiceManager, service_name: str) -> bool:
90
+ """处理启动命令"""
91
+ if service_name == 'all':
92
+ service_names = manager.get_service_names()
93
+ if not service_names:
94
+ print(f"{Emojis.WARNING} {Colors.YELLOW}配置中没有定义任何服务。{Colors.RESET}")
95
+ return False
96
+
97
+ print(f"{Emojis.START} {Colors.BRIGHT_GREEN}正在启动所有服务...{Colors.RESET}")
98
+ success = True
99
+ for name in service_names:
100
+ if not manager.start(name):
101
+ print(f"{Emojis.ERROR} {Colors.RED}启动服务 '{name}' 失败{Colors.RESET}")
102
+ success = False
103
+ else:
104
+ print(f"{Emojis.RUNNING} {Colors.GREEN}服务 '{name}' 已成功启动{Colors.RESET}")
105
+
106
+ if success:
107
+ print(f"\n{Emojis.RUNNING} {Colors.BRIGHT_GREEN}所有服务已成功启动!{Colors.RESET}")
108
+ else:
109
+ print(f"\n{Emojis.WARNING} {Colors.YELLOW}有些服务启动失败,请检查日志获取详情。{Colors.RESET}")
110
+
111
+ return success
112
+ else:
113
+ result = manager.start(service_name)
114
+ if result:
115
+ print(f"{Emojis.RUNNING} {Colors.GREEN}服务 '{service_name}' 已成功启动{Colors.RESET}")
116
+ else:
117
+ print(f"{Emojis.ERROR} {Colors.RED}启动服务 '{service_name}' 失败{Colors.RESET}")
118
+ return result
119
+
120
+ def handle_stop(manager: ServiceManager, service_name: str) -> bool:
121
+ """处理停止命令"""
122
+ if service_name == 'all':
123
+ service_names = manager.get_running_services()
124
+ if not service_names:
125
+ print(f"{Emojis.INFO} {Colors.BRIGHT_BLACK}当前没有正在运行的服务。{Colors.RESET}")
126
+ return True
127
+
128
+ print(f"{Emojis.STOP} {Colors.YELLOW}正在停止所有服务...{Colors.RESET}")
129
+ success = True
130
+ for name in service_names:
131
+ if not manager.stop(name):
132
+ print(f"{Emojis.ERROR} {Colors.RED}停止服务 '{name}' 失败{Colors.RESET}")
133
+ success = False
134
+ else:
135
+ print(f"{Emojis.STOPPED} {Colors.YELLOW}服务 '{name}' 已停止{Colors.RESET}")
136
+
137
+ if success:
138
+ print(f"\n{Emojis.STOPPED} {Colors.YELLOW}所有服务已成功停止!{Colors.RESET}")
139
+ else:
140
+ print(f"\n{Emojis.WARNING} {Colors.YELLOW}有些服务停止失败,请检查日志获取详情。{Colors.RESET}")
141
+
142
+ return success
143
+ else:
144
+ result = manager.stop(service_name)
145
+ if result:
146
+ print(f"{Emojis.STOPPED} {Colors.YELLOW}服务 '{service_name}' 已成功停止{Colors.RESET}")
147
+ else:
148
+ print(f"{Emojis.ERROR} {Colors.RED}停止服务 '{service_name}' 失败{Colors.RESET}")
149
+ return result
150
+
151
+ def handle_restart(manager: ServiceManager, service_name: str) -> bool:
152
+ """处理重启命令"""
153
+ if service_name == 'all':
154
+ service_names = manager.get_service_names()
155
+ if not service_names:
156
+ print(f"{Emojis.WARNING} {Colors.YELLOW}配置中没有定义任何服务。{Colors.RESET}")
157
+ return False
158
+
159
+ print(f"{Emojis.RESTART} {Colors.MAGENTA}正在重启所有服务...{Colors.RESET}")
160
+ success = True
161
+ for name in service_names:
162
+ if not manager.restart(name):
163
+ print(f"{Emojis.ERROR} {Colors.RED}重启服务 '{name}' 失败{Colors.RESET}")
164
+ success = False
165
+ else:
166
+ print(f"{Emojis.RUNNING} {Colors.MAGENTA}服务 '{name}' 已成功重启{Colors.RESET}")
167
+
168
+ if success:
169
+ print(f"\n{Emojis.RUNNING} {Colors.BRIGHT_GREEN}所有服务已成功重启!{Colors.RESET}")
170
+ else:
171
+ print(f"\n{Emojis.WARNING} {Colors.YELLOW}有些服务重启失败,请检查日志获取详情。{Colors.RESET}")
172
+
173
+ return success
174
+ else:
175
+ result = manager.restart(service_name)
176
+ if result:
177
+ print(f"{Emojis.RUNNING} {Colors.MAGENTA}服务 '{service_name}' 已成功重启{Colors.RESET}")
178
+ else:
179
+ print(f"{Emojis.ERROR} {Colors.RED}重启服务 '{service_name}' 失败{Colors.RESET}")
180
+ return result
181
+
182
+ def handle_log(manager: ServiceManager, log_manager: LogManager, args) -> bool:
183
+ """处理日志查看命令"""
184
+ service_name = args.service
185
+ follow = not args.no_follow
186
+ lines = args.lines
187
+
188
+ if service_name == 'all':
189
+ services = manager.get_service_names()
190
+ if not services:
191
+ print(f"{Emojis.WARNING} {Colors.YELLOW}配置中没有定义任何服务。{Colors.RESET}")
192
+ return False
193
+ else:
194
+ if service_name not in manager.get_service_names():
195
+ print(f"{Emojis.ERROR} {Colors.RED}服务 '{service_name}' 在配置中未找到。{Colors.RESET}")
196
+ return False
197
+ services = [service_name]
198
+
199
+ # 已经在 LogManager 中添加了彩色输出
200
+ log_manager.tail_logs(services, follow=follow, lines=lines)
201
+ return True
202
+
203
+ def handle_list(manager: ServiceManager) -> bool:
204
+ """处理列出服务命令"""
205
+ service_names = manager.get_service_names()
206
+
207
+ if not service_names:
208
+ print(f"{Emojis.WARNING} {Colors.YELLOW}配置中没有定义任何服务。{Colors.RESET}")
209
+ return True
210
+
211
+ # 表头
212
+ print(f"\n{Colors.CYAN}{Colors.BOLD}{Emojis.SERVICE} 服务列表{Colors.RESET}")
213
+ print(f"{Colors.CYAN}{'─' * 60}{Colors.RESET}")
214
+
215
+ # 列标题
216
+ print(f"{Colors.BOLD}{align_colored_text(' 名称', 25)} {align_colored_text('状态', 20)} {align_colored_text('PID', 10)}{Colors.RESET}")
217
+ print(f"{Colors.BRIGHT_BLACK}{'─' * 60}{Colors.RESET}")
218
+
219
+ # 服务列表
220
+ for name in service_names:
221
+ is_running = manager.is_running(name)
222
+ pid = manager.get_service_pid(name) or '-'
223
+
224
+ if is_running:
225
+ status_text = f"{Emojis.RUNNING} {Colors.GREEN}RUNNING{Colors.RESET}"
226
+ pid_text = f"{Colors.GREEN}{pid}{Colors.RESET}"
227
+ else:
228
+ status_text = f"{Emojis.STOPPED} {Colors.BRIGHT_BLACK}STOPPED{Colors.RESET}"
229
+ pid_text = f"{Colors.BRIGHT_BLACK}{pid}{Colors.RESET}"
230
+
231
+ # 为不同服务使用不同颜色
232
+ service_color = Colors.CYAN if is_running else Colors.BRIGHT_BLACK
233
+ service_text = f"{service_color}{name}{Colors.RESET}"
234
+
235
+ # 使用对齐辅助函数确保正确对齐包含颜色代码的文本
236
+ aligned_service = align_colored_text(f" {service_text}", 25)
237
+ aligned_status = align_colored_text(status_text, 20)
238
+ aligned_pid = align_colored_text(pid_text, 10)
239
+
240
+ print(f"{aligned_service}{aligned_status}{aligned_pid}")
241
+
242
+ print(f"\n{Colors.BRIGHT_BLACK}配置文件: {manager.config_path}{Colors.RESET}")
243
+ print(f"{Colors.BRIGHT_BLACK}运行中服务: {len(manager.get_running_services())}/{len(service_names)}{Colors.RESET}")
244
+ print()
245
+
246
+ return True
247
+
248
+ def main():
249
+ """CLI 应用程序入口点"""
250
+ # 显示头部
251
+ print_header()
252
+
253
+ parser = setup_arg_parser()
254
+ args = parser.parse_args()
255
+
256
+ if not args.command:
257
+ parser.print_help()
258
+ return 1
259
+
260
+ # 创建服务管理器
261
+ try:
262
+ service_manager = ServiceManager(config_path=args.config)
263
+ except Exception as e:
264
+ print(f"{Emojis.ERROR} {Colors.RED}加载配置文件时出错: {e}{Colors.RESET}")
265
+ return 1
266
+
267
+ # 创建日志管理器
268
+ log_manager = LogManager(service_manager.log_dir)
269
+
270
+ # 处理命令
271
+ try:
272
+ if args.command == 'start':
273
+ success = handle_start(service_manager, args.service)
274
+ elif args.command == 'stop':
275
+ success = handle_stop(service_manager, args.service)
276
+ elif args.command == 'restart':
277
+ success = handle_restart(service_manager, args.service)
278
+ elif args.command == 'log':
279
+ success = handle_log(service_manager, log_manager, args)
280
+ elif args.command == 'list':
281
+ success = handle_list(service_manager)
282
+ else:
283
+ parser.print_help()
284
+ return 1
285
+ except KeyboardInterrupt:
286
+ print(f"\n{Emojis.STOP} {Colors.BRIGHT_BLACK}操作被用户中断{Colors.RESET}")
287
+ return 1
288
+ except Exception as e:
289
+ print(f"\n{Emojis.ERROR} {Colors.RED}执行命令时出错: {e}{Colors.RESET}")
290
+ return 1
291
+
292
+ return 0 if success else 1
293
+
294
+ if __name__ == "__main__":
295
+ sys.exit(main())
servly/logs.py ADDED
@@ -0,0 +1,266 @@
1
+ """
2
+ Log management functionality for Servly.
3
+ """
4
+ import os
5
+ import sys
6
+ import time
7
+ import select
8
+ import fcntl
9
+ import termios
10
+ import subprocess
11
+ import re
12
+ from pathlib import Path
13
+ from typing import List, Dict, Optional, Tuple
14
+ import random
15
+
16
+
17
+ # ANSI 颜色代码
18
+ class Colors:
19
+ RESET = "\033[0m"
20
+ BOLD = "\033[1m"
21
+ UNDERLINE = "\033[4m"
22
+
23
+ BLACK = "\033[30m"
24
+ RED = "\033[31m"
25
+ GREEN = "\033[32m"
26
+ YELLOW = "\033[33m"
27
+ BLUE = "\033[34m"
28
+ MAGENTA = "\033[35m"
29
+ CYAN = "\033[36m"
30
+ WHITE = "\033[37m"
31
+
32
+ BRIGHT_BLACK = "\033[90m"
33
+ BRIGHT_RED = "\033[91m"
34
+ BRIGHT_GREEN = "\033[92m"
35
+ BRIGHT_YELLOW = "\033[93m"
36
+ BRIGHT_BLUE = "\033[94m"
37
+ BRIGHT_MAGENTA = "\033[95m"
38
+ BRIGHT_CYAN = "\033[96m"
39
+ BRIGHT_WHITE = "\033[97m"
40
+
41
+ # 背景色
42
+ BG_BLACK = "\033[40m"
43
+ BG_RED = "\033[41m"
44
+ BG_GREEN = "\033[42m"
45
+ BG_YELLOW = "\033[43m"
46
+ BG_BLUE = "\033[44m"
47
+ BG_MAGENTA = "\033[45m"
48
+ BG_CYAN = "\033[46m"
49
+ BG_WHITE = "\033[47m"
50
+
51
+
52
+ # 服务相关的 emoji
53
+ class Emojis:
54
+ SERVICE = "🔧"
55
+ START = "🟢"
56
+ STOP = "🔴"
57
+ RESTART = "🔄"
58
+ INFO = "ℹ️ "
59
+ WARNING = "⚠️ "
60
+ ERROR = "❌"
61
+ LOG = "📝"
62
+ STDOUT = "📤"
63
+ STDERR = "📥"
64
+ TIME = "🕒"
65
+ RUNNING = "✅"
66
+ STOPPED = "⛔"
67
+ LOADING = "⏳"
68
+
69
+
70
+ # 处理彩色文本对齐的辅助函数
71
+ def strip_ansi_codes(text: str) -> str:
72
+ """删除字符串中的所有 ANSI 转义代码"""
73
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
74
+ return ansi_escape.sub('', text)
75
+
76
+ def get_visible_length(text: str) -> int:
77
+ """获取文本在终端中的可见长度(排除 ANSI 代码和宽字符)"""
78
+ text = strip_ansi_codes(text)
79
+ # 处理中文等宽字符(在大多数终端中占用两个字符宽度)
80
+ length = 0
81
+ for char in text:
82
+ if ord(char) > 127: # 简单判断是否是非ASCII字符
83
+ length += 2
84
+ else:
85
+ length += 1
86
+ return length
87
+
88
+ def align_colored_text(text: str, width: int, align='left') -> str:
89
+ """
90
+ 将彩色文本对齐到指定宽度
91
+
92
+ Args:
93
+ text: 可能包含 ANSI 颜色代码的文本
94
+ width: 期望的显示宽度
95
+ align: 对齐方式,'left', 'right' 或 'center'
96
+
97
+ Returns:
98
+ 对齐后的文本,保留颜色代码
99
+ """
100
+ visible_length = get_visible_length(text)
101
+ padding = max(0, width - visible_length)
102
+
103
+ if align == 'right':
104
+ return ' ' * padding + text
105
+ elif align == 'center':
106
+ left_padding = padding // 2
107
+ right_padding = padding - left_padding
108
+ return ' ' * left_padding + text + ' ' * right_padding
109
+ else: # left alignment
110
+ return text + ' ' * padding
111
+
112
+
113
+ class LogManager:
114
+ """Handles viewing and managing logs for servly services."""
115
+
116
+ def __init__(self, log_dir: Path):
117
+ self.log_dir = log_dir
118
+ # 为每个服务分配一个固定颜色,使日志更易读
119
+ self.service_colors = {}
120
+ self.available_colors = [
121
+ Colors.GREEN, Colors.YELLOW, Colors.BLUE, Colors.MAGENTA, Colors.CYAN,
122
+ Colors.BRIGHT_GREEN, Colors.BRIGHT_YELLOW, Colors.BRIGHT_BLUE,
123
+ Colors.BRIGHT_MAGENTA, Colors.BRIGHT_CYAN
124
+ ]
125
+
126
+ def get_log_files(self, service_name: str) -> Dict[str, Path]:
127
+ """Get the stdout and stderr log files for a service."""
128
+ return {
129
+ 'stdout': self.log_dir / f"{service_name}-out.log",
130
+ 'stderr': self.log_dir / f"{service_name}-error.log"
131
+ }
132
+
133
+ def _get_service_color(self, service_name: str) -> str:
134
+ """为服务分配一个固定的颜色"""
135
+ if service_name not in self.service_colors:
136
+ if not self.available_colors:
137
+ # 如果颜色用完了,就随机分配
138
+ self.service_colors[service_name] = random.choice([
139
+ Colors.GREEN, Colors.YELLOW, Colors.BLUE, Colors.MAGENTA, Colors.CYAN
140
+ ])
141
+ else:
142
+ # 从可用颜色中选择一个
143
+ self.service_colors[service_name] = self.available_colors.pop(0)
144
+ return self.service_colors[service_name]
145
+
146
+ def _format_log_header(self, service: str, log_type: str) -> str:
147
+ """格式化日志头部,带颜色和 emoji"""
148
+ service_color = self._get_service_color(service)
149
+ emoji = Emojis.STDOUT if log_type == 'stdout' else Emojis.STDERR
150
+ type_color = Colors.GREEN if log_type == 'stdout' else Colors.YELLOW
151
+
152
+ return (f"\n{Colors.BOLD}{Colors.WHITE}{Emojis.LOG} Log Stream: "
153
+ f"{service_color}{service}{Colors.RESET} "
154
+ f"{Colors.BRIGHT_BLACK}({type_color}{emoji} {log_type}{Colors.BRIGHT_BLACK}){Colors.RESET}\n"
155
+ f"{Colors.BRIGHT_BLACK}{'─' * 60}{Colors.RESET}")
156
+
157
+ def _format_log_line(self, service: str, log_type: str, line: str, show_timestamp: bool = True) -> str:
158
+ """格式化单行日志,带颜色和 emoji"""
159
+ service_color = self._get_service_color(service)
160
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S") if show_timestamp else ""
161
+
162
+ # 根据日志类型选择颜色
163
+ line_color = Colors.RESET
164
+ if log_type == 'stderr' and ('error' in line.lower() or 'exception' in line.lower()):
165
+ prefix = f"{Emojis.ERROR} "
166
+ line_color = Colors.BRIGHT_RED
167
+ elif log_type == 'stderr':
168
+ prefix = f"{Emojis.WARNING} "
169
+ line_color = Colors.YELLOW
170
+ else:
171
+ prefix = f"{Emojis.INFO} "
172
+
173
+ # 格式化输出
174
+ if show_timestamp:
175
+ return (f"{Colors.BRIGHT_BLACK}[{Emojis.TIME} {timestamp}]{Colors.RESET} "
176
+ f"{service_color}{service}{Colors.RESET} "
177
+ f"{prefix}{line_color}{line.rstrip()}{Colors.RESET}")
178
+ else:
179
+ return f"{line_color}{line.rstrip()}{Colors.RESET}"
180
+
181
+ def tail_logs(self, service_names: List[str], follow: bool = True, lines: int = 10):
182
+ """
183
+ Display logs for specified services in real-time.
184
+
185
+ Args:
186
+ service_names: List of service names to show logs for
187
+ follow: Whether to follow logs in real-time (like tail -f)
188
+ lines: Number of recent lines to display initially
189
+ """
190
+ if not service_names:
191
+ print(f"{Emojis.WARNING} {Colors.YELLOW}No services specified for log viewing.{Colors.RESET}")
192
+ return
193
+
194
+ # Check if the logs exist
195
+ log_files = []
196
+ for service in service_names:
197
+ service_logs = self.get_log_files(service)
198
+ for log_type, log_path in service_logs.items():
199
+ if log_path.exists():
200
+ log_files.append((service, log_type, log_path))
201
+ else:
202
+ service_color = self._get_service_color(service)
203
+ print(f"{Emojis.WARNING} {Colors.YELLOW}No {log_type} logs found for {service_color}{service}{Colors.RESET}.")
204
+
205
+ if not log_files:
206
+ print(f"{Emojis.WARNING} {Colors.YELLOW}No log files found for specified services.{Colors.RESET}")
207
+ return
208
+
209
+ if follow:
210
+ self._follow_logs(log_files)
211
+ else:
212
+ self._display_recent_logs(log_files, lines)
213
+
214
+ def _display_recent_logs(self, log_files: List[Tuple[str, str, Path]], lines: int):
215
+ """Display the most recent lines from log files."""
216
+ for service, log_type, log_path in log_files:
217
+ print(self._format_log_header(service, log_type))
218
+ try:
219
+ # 读取最后N行
220
+ with open(log_path, 'r') as f:
221
+ content = f.readlines()
222
+ last_lines = content[-lines:] if len(content) >= lines else content
223
+
224
+ # 打印每一行,增加格式
225
+ for line in last_lines:
226
+ print(self._format_log_line(service, log_type, line, show_timestamp=False))
227
+ except Exception as e:
228
+ print(f"{Emojis.ERROR} {Colors.RED}Error reading logs: {str(e)}{Colors.RESET}")
229
+
230
+ def _follow_logs(self, log_files: List[Tuple[str, str, Path]]):
231
+ """Follow logs in real-time, similar to tail -f."""
232
+ # Dictionary to keep track of file positions
233
+ file_handlers = {}
234
+
235
+ try:
236
+ # Open all log files
237
+ for service, log_type, log_path in log_files:
238
+ f = open(log_path, 'r')
239
+ # Move to the end of the file
240
+ f.seek(0, os.SEEK_END)
241
+ file_handlers[(service, log_type)] = f
242
+
243
+ # 打印日志头部
244
+ print(self._format_log_header(service, log_type))
245
+
246
+ print(f"\n{Emojis.LOADING} {Colors.BRIGHT_BLACK}Following logs... (Ctrl+C to stop){Colors.RESET}")
247
+ print(f"{Colors.BRIGHT_BLACK}{'─' * 60}{Colors.RESET}")
248
+
249
+ while True:
250
+ has_new_data = False
251
+
252
+ for (service, log_type), f in file_handlers.items():
253
+ line = f.readline()
254
+ if line:
255
+ has_new_data = True
256
+ print(self._format_log_line(service, log_type, line))
257
+
258
+ if not has_new_data:
259
+ time.sleep(0.1)
260
+
261
+ except KeyboardInterrupt:
262
+ print(f"\n{Emojis.STOP} {Colors.BRIGHT_BLACK}Stopped following logs.{Colors.RESET}")
263
+ finally:
264
+ # Close all file handlers
265
+ for f in file_handlers.values():
266
+ f.close()
servly/service.py ADDED
@@ -0,0 +1,213 @@
1
+ """
2
+ Service management functionality for Servly.
3
+ """
4
+ import os
5
+ import subprocess
6
+ import signal
7
+ import yaml
8
+ import logging
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Dict, Union, List, Optional, Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class ServiceManager:
16
+ """Manages processes based on servly.yml configuration."""
17
+
18
+ def __init__(self, config_path: str = "servly.yml", servly_dir: str = ".servly"):
19
+ self.config_path = config_path
20
+ self.servly_dir = Path(servly_dir)
21
+ self.pid_dir = self.servly_dir / "pids"
22
+ self.log_dir = self.servly_dir / "logs"
23
+ self._ensure_dirs()
24
+ self.services = self._load_config()
25
+
26
+ def _ensure_dirs(self):
27
+ """Create required directories if they don't exist."""
28
+ self.pid_dir.mkdir(parents=True, exist_ok=True)
29
+ self.log_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+ def _load_config(self) -> Dict[str, Any]:
32
+ """Load service configurations from servly.yml."""
33
+ if not os.path.exists(self.config_path):
34
+ logger.error(f"Configuration file not found: {self.config_path}")
35
+ return {}
36
+
37
+ try:
38
+ with open(self.config_path, 'r') as file:
39
+ config = yaml.safe_load(file) or {}
40
+
41
+ # Validate config and convert simple format to detailed format
42
+ validated_config = {}
43
+ for name, conf in config.items():
44
+ if name.lower() == "servly":
45
+ logger.warning(f"'servly' is a reserved name and cannot be used as a service name.")
46
+ continue
47
+
48
+ if isinstance(conf, str):
49
+ validated_config[name] = {"cmd": conf}
50
+ elif isinstance(conf, dict) and "cmd" in conf:
51
+ validated_config[name] = conf
52
+ elif isinstance(conf, dict) and "script" in conf:
53
+ validated_config[name] = conf
54
+ # Convert script to cmd for consistency
55
+ validated_config[name]["cmd"] = conf["script"]
56
+ else:
57
+ logger.warning(f"Invalid configuration for service '{name}', skipping.")
58
+
59
+ return validated_config
60
+ except Exception as e:
61
+ logger.error(f"Error loading configuration: {str(e)}")
62
+ return {}
63
+
64
+ def get_pid_file(self, service_name: str) -> Path:
65
+ """Get the path to a service's PID file."""
66
+ return self.pid_dir / f"{service_name}.pid"
67
+
68
+ def get_service_pid(self, service_name: str) -> Optional[int]:
69
+ """Get the PID for a running service, or None if not running."""
70
+ pid_file = self.get_pid_file(service_name)
71
+ if not pid_file.exists():
72
+ return None
73
+
74
+ try:
75
+ with open(pid_file, 'r') as f:
76
+ pid = int(f.read().strip())
77
+
78
+ # Check if process is still running
79
+ if self._is_process_running(pid):
80
+ return pid
81
+ else:
82
+ # Clean up stale PID file
83
+ os.remove(pid_file)
84
+ return None
85
+ except (ValueError, FileNotFoundError):
86
+ return None
87
+
88
+ def _is_process_running(self, pid: int) -> bool:
89
+ """Check if process with given PID is running."""
90
+ try:
91
+ os.kill(pid, 0)
92
+ return True
93
+ except ProcessLookupError:
94
+ return False
95
+ except PermissionError:
96
+ # Process exists but we don't have permission to send signals to it
97
+ return True
98
+
99
+ def is_running(self, service_name: str) -> bool:
100
+ """Check if a service is running."""
101
+ return self.get_service_pid(service_name) is not None
102
+
103
+ def get_service_names(self) -> List[str]:
104
+ """Get list of configured service names."""
105
+ return list(self.services.keys())
106
+
107
+ def get_running_services(self) -> List[str]:
108
+ """Get list of currently running services."""
109
+ return [name for name in self.get_service_names() if self.is_running(name)]
110
+
111
+ def start(self, service_name: str) -> bool:
112
+ """Start a specified service."""
113
+ if service_name not in self.services:
114
+ logger.error(f"Service '{service_name}' not found in configuration.")
115
+ return False
116
+
117
+ if self.is_running(service_name):
118
+ logger.info(f"Service '{service_name}' is already running.")
119
+ return True
120
+
121
+ config = self.services[service_name]
122
+ cmd = config.get("cmd")
123
+ if not cmd:
124
+ logger.error(f"No command specified for service '{service_name}'.")
125
+ return False
126
+
127
+ # Prepare environment variables
128
+ env = os.environ.copy()
129
+ if "env" in config and isinstance(config["env"], dict):
130
+ env.update(config["env"])
131
+
132
+ # Prepare working directory
133
+ cwd = config.get("cwd", os.getcwd())
134
+
135
+ # Prepare log files
136
+ stdout_log = self.log_dir / f"{service_name}-out.log"
137
+ stderr_log = self.log_dir / f"{service_name}-error.log"
138
+
139
+ try:
140
+ with open(stdout_log, 'a') as out, open(stderr_log, 'a') as err:
141
+ # Add timestamp to logs
142
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
143
+ out.write(f"\n--- Starting service '{service_name}' at {timestamp} ---\n")
144
+ err.write(f"\n--- Starting service '{service_name}' at {timestamp} ---\n")
145
+
146
+ # Start the process
147
+ process = subprocess.Popen(
148
+ cmd,
149
+ shell=True,
150
+ stdout=out,
151
+ stderr=err,
152
+ cwd=cwd,
153
+ env=env,
154
+ start_new_session=True # Detach from current process group
155
+ )
156
+
157
+ # Save the PID to file
158
+ with open(self.get_pid_file(service_name), 'w') as f:
159
+ f.write(str(process.pid))
160
+
161
+ logger.info(f"Started service '{service_name}' with PID {process.pid}")
162
+ return True
163
+
164
+ except Exception as e:
165
+ logger.error(f"Failed to start service '{service_name}': {str(e)}")
166
+ return False
167
+
168
+ def stop(self, service_name: str, timeout: int = 5) -> bool:
169
+ """Stop a specified service."""
170
+ pid = self.get_service_pid(service_name)
171
+ if not pid:
172
+ logger.info(f"Service '{service_name}' is not running.")
173
+ return True
174
+
175
+ try:
176
+ # First try SIGTERM for graceful shutdown
177
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
178
+
179
+ # Wait for process to terminate
180
+ for _ in range(timeout):
181
+ if not self._is_process_running(pid):
182
+ break
183
+ time.sleep(1)
184
+
185
+ # If still running, force kill with SIGKILL
186
+ if self._is_process_running(pid):
187
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
188
+ time.sleep(0.5)
189
+
190
+ # Clean up PID file
191
+ pid_file = self.get_pid_file(service_name)
192
+ if os.path.exists(pid_file):
193
+ os.remove(pid_file)
194
+
195
+ logger.info(f"Stopped service '{service_name}'")
196
+ return True
197
+
198
+ except ProcessLookupError:
199
+ # Process already terminated
200
+ pid_file = self.get_pid_file(service_name)
201
+ if os.path.exists(pid_file):
202
+ os.remove(pid_file)
203
+ logger.info(f"Service '{service_name}' was not running")
204
+ return True
205
+
206
+ except Exception as e:
207
+ logger.error(f"Failed to stop service '{service_name}': {str(e)}")
208
+ return False
209
+
210
+ def restart(self, service_name: str) -> bool:
211
+ """Restart a service."""
212
+ self.stop(service_name)
213
+ return self.start(service_name)
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: servly
3
+ Version: 0.2.0
4
+ Summary: simple process manager
5
+ Author-email: simpxx <simpxx@gmail.com>
6
+ License: MIT
7
+ Keywords: command-line,tool
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: pyyaml>=6.0.2
14
+
15
+ # SERVLY
16
+
17
+ A simple process management tool for Linux, similar to PM2, designed to simplify application deployment and management.
18
+
19
+ ## Features
20
+
21
+ - Start, stop, and restart services defined in a `servly.yml` configuration file
22
+ - View real-time logs with service name highlighting
23
+ - Automatic process supervision and PID management
24
+ - Environment variable support
25
+ - Simple YAML configuration
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ # Using pip
31
+ pip install servly
32
+
33
+ # From source
34
+ git clone https://github.com/yourusername/servly.git
35
+ cd servly
36
+ pip install -e .
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### Quick Start
42
+
43
+ 1. Create a `servly.yml` file in your project:
44
+
45
+ ```yaml
46
+ # Basic format: service-name: command
47
+ web-server: node server.js
48
+
49
+ # Detailed format
50
+ api:
51
+ cmd: python api.py
52
+ cwd: ./api
53
+ env:
54
+ NODE_ENV: production
55
+ ```
56
+
57
+ 2. Start your services:
58
+
59
+ ```bash
60
+ servly start
61
+ ```
62
+
63
+ ### Core Commands
64
+
65
+ - **Start Services**:
66
+ ```bash
67
+ servly start [all | service-name]
68
+ ```
69
+ Starts all services or a specific service by name.
70
+
71
+ - **Stop Services**:
72
+ ```bash
73
+ servly stop [all | service-name]
74
+ ```
75
+ Stops all running services or a specific service by name.
76
+
77
+ - **Restart Services**:
78
+ ```bash
79
+ servly restart [all | service-name]
80
+ ```
81
+ Restarts all services or a specific service by name.
82
+
83
+ - **View Service Logs**:
84
+ ```bash
85
+ servly log [all | service-name]
86
+ ```
87
+ Shows logs in real-time (similar to `tail -f`).
88
+
89
+ - **List Services**:
90
+ ```bash
91
+ servly list
92
+ ```
93
+ Shows status of all configured services.
94
+
95
+ ## Configuration
96
+
97
+ ### servly.yml
98
+
99
+ The `servly.yml` file supports two formats:
100
+
101
+ 1. **Simple format**:
102
+ ```yaml
103
+ service-name: command to run
104
+ ```
105
+
106
+ 2. **Detailed format**:
107
+ ```yaml
108
+ service-name:
109
+ cmd: command to run
110
+ cwd: working directory (optional)
111
+ env:
112
+ ENV_VAR1: value1
113
+ ENV_VAR2: value2
114
+ ```
115
+
116
+ **Note**: The name "servly" is reserved and cannot be used as a service name.
117
+
118
+ ### Directory Structure
119
+
120
+ Servly creates a `.servly` directory to store runtime information:
121
+
122
+ ```
123
+ .servly/
124
+ ├── logs/
125
+ │ ├── [service-name]-out.log # Standard output logs
126
+ │ └── [service-name]-error.log # Error logs
127
+ ├── pids/
128
+ │ └── [service-name].pid # Process ID files
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,9 @@
1
+ servly/__init__.py,sha256=HT8I94RyP6_gNoVJWOVSkeOEQIl3QnT60zCP2PJVon0,73
2
+ servly/cli.py,sha256=HeOOsnaJGMaiSWQ5Z-hkCLjxpmoUfES37hgfTHBuDMk,12142
3
+ servly/logs.py,sha256=HfC_EByTbgz6MpW8IpemEwpRPPE0xME46zUy2CyJvqU,9779
4
+ servly/service.py,sha256=5xhSD0HkfQw8xnY5J0GRxyFE8_CHBq5yfatbhL625PM,8123
5
+ servly-0.2.0.dist-info/METADATA,sha256=YICRthT2IW92tOXYTUmZN1cCGZ3LtWBhtLPIIZADKeA,2630
6
+ servly-0.2.0.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
7
+ servly-0.2.0.dist-info/entry_points.txt,sha256=enHpE2d9DYdzfIWBcnIjuIkcNVk118xghL0AeTUL0Yg,43
8
+ servly-0.2.0.dist-info/top_level.txt,sha256=JLi7GX0KwWF-mh8i1-lVJ2q4SO1KfI2CNxQk_VNfNZE,7
9
+ servly-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ servly = servly.cli:main
@@ -0,0 +1 @@
1
+ servly
@@ -1,14 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: servly
3
- Version: 0.1.0
4
- Summary: simple process manager
5
- Author-email: simpxx <simpxx@gmail.com>
6
- License: MIT
7
- Keywords: command-line,tool
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.12
12
- Description-Content-Type: text/markdown
13
-
14
- # XM
@@ -1,7 +0,0 @@
1
- xm/__init__.py,sha256=L6zbQIZKsAP-Knhm6fBcQFPoVdIDuejxze60qX23jiw,21
2
- xm/cli.py,sha256=Wb9RNHjD2vJ3sysY52U9gORMDA6bVSnZ4sd6MmoA9rc,108
3
- servly-0.1.0.dist-info/METADATA,sha256=RwhsvIBoTDT6sg_UyUjU_H5kCe_6MHF-iA_xYn4HFsE,379
4
- servly-0.1.0.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
5
- servly-0.1.0.dist-info/entry_points.txt,sha256=tNnO4et40QVUJwNa0i2e5lOvMpcba6chlfwK4d4-SpA,35
6
- servly-0.1.0.dist-info/top_level.txt,sha256=4v0vf9bT58AtJn_CpjgroekLoaoDDo5QvmFqNby7gPc,3
7
- servly-0.1.0.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- xm = xm.cli:main
@@ -1 +0,0 @@
1
- xm
xm/__init__.py DELETED
@@ -1 +0,0 @@
1
- __version__ = '0.1.0'
xm/cli.py DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- def main():
4
- print("Hello World from xm!")
5
-
6
- if __name__ == "__main__":
7
- main()
File without changes