xutk 0.3.4__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.
xutk-0.3.4/PKG-INFO ADDED
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: xutk
3
+ Version: 0.3.4
4
+ Summary: Xulab toolkit in python
5
+ Requires-Python: <3.15,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: colorama>=0.4.6
8
+ Requires-Dist: psutil>=7.1.0
9
+
10
+ # Xulab Useful Toolkit in Python (xutk)
11
+
12
+ ![Maintenance](https://img.shields.io/maintenance/Zelin2001/2025)
13
+ ![PyPI - Python Version](https://img.shields.io/badge/python-3.11|3.12|3.13|3.14|3.14t-blue.svg)
14
+
15
+ ## 导入项目
16
+
17
+ ```bash
18
+ pip install \
19
+ --trusted-host "gitea.xulab.ion.ac.cn" \
20
+ --extra-index-url "http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple/" \
21
+ xutk
22
+ ```
23
+
24
+ 或者使用 uv:
25
+
26
+ ```bash
27
+ uv add --index http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple xutk
28
+ ```
29
+
30
+ ## 快速开始
31
+
32
+ ```python
33
+ from xutk import verchk, log, mp_runner
34
+ from pathlib import Path
35
+
36
+ # 版本检查
37
+ verchk.check_version("0.2.0", "some_package")
38
+
39
+ # 日志记录
40
+ logger = log.CtxLogger("my_app")
41
+ logger.info({"module": "processor", "file": "data.csv"}, "Processing started")
42
+
43
+ # 多进程运行器
44
+ runner = mp_runner.mprunner_factory(2, log_file=Path("process.log")) # 进程队列,取 2 个运行
45
+ runner.batch_run(["echo", "-E", f"test{num}"] for num in range(1,25)) # 排队 25 个进程
46
+ ```
47
+
48
+ **建议在项目 `__init__.py` 创建 CtxLogger("my_app"),避免实例化顺序问题。**
49
+
50
+ ## 项目结构
51
+
52
+ ```text
53
+ xutk/
54
+ ├── xutk/
55
+ │ ├── __init__.py # 包初始化
56
+ │ ├── log.py # 日志记录功能
57
+ │ ├── mp_runner.py # 多进程运行管理
58
+ │ ├── perf.py # 快速资源检查
59
+ │ └── verchk.py # 版本检查功能
60
+ ├── test/
61
+ │ ├── integration/ # 集成测试
62
+ │ └── unit/ # 单元测试
63
+ ├── gitea/
64
+ │ └── workflows/ # CI 工作流
65
+ ├── pyproject.toml # 项目配置
66
+ └── README.md # 项目文档
67
+ ```
68
+
69
+ ## 开发
70
+
71
+ ### 安装开发依赖
72
+
73
+ ```bash
74
+ uv sync --all-extras
75
+ ```
76
+
77
+ ### 运行测试
78
+
79
+ ```bash
80
+ uv run pytest
81
+ ```
82
+
83
+ ### 代码检查
84
+
85
+ ```bash
86
+ uv run ruff check # 代码风格检查
87
+ uv run ty check # 类型检查
88
+ uv run pytest # 样例测试
89
+ ```
xutk-0.3.4/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Xulab Useful Toolkit in Python (xutk)
2
+
3
+ ![Maintenance](https://img.shields.io/maintenance/Zelin2001/2025)
4
+ ![PyPI - Python Version](https://img.shields.io/badge/python-3.11|3.12|3.13|3.14|3.14t-blue.svg)
5
+
6
+ ## 导入项目
7
+
8
+ ```bash
9
+ pip install \
10
+ --trusted-host "gitea.xulab.ion.ac.cn" \
11
+ --extra-index-url "http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple/" \
12
+ xutk
13
+ ```
14
+
15
+ 或者使用 uv:
16
+
17
+ ```bash
18
+ uv add --index http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple xutk
19
+ ```
20
+
21
+ ## 快速开始
22
+
23
+ ```python
24
+ from xutk import verchk, log, mp_runner
25
+ from pathlib import Path
26
+
27
+ # 版本检查
28
+ verchk.check_version("0.2.0", "some_package")
29
+
30
+ # 日志记录
31
+ logger = log.CtxLogger("my_app")
32
+ logger.info({"module": "processor", "file": "data.csv"}, "Processing started")
33
+
34
+ # 多进程运行器
35
+ runner = mp_runner.mprunner_factory(2, log_file=Path("process.log")) # 进程队列,取 2 个运行
36
+ runner.batch_run(["echo", "-E", f"test{num}"] for num in range(1,25)) # 排队 25 个进程
37
+ ```
38
+
39
+ **建议在项目 `__init__.py` 创建 CtxLogger("my_app"),避免实例化顺序问题。**
40
+
41
+ ## 项目结构
42
+
43
+ ```text
44
+ xutk/
45
+ ├── xutk/
46
+ │ ├── __init__.py # 包初始化
47
+ │ ├── log.py # 日志记录功能
48
+ │ ├── mp_runner.py # 多进程运行管理
49
+ │ ├── perf.py # 快速资源检查
50
+ │ └── verchk.py # 版本检查功能
51
+ ├── test/
52
+ │ ├── integration/ # 集成测试
53
+ │ └── unit/ # 单元测试
54
+ ├── gitea/
55
+ │ └── workflows/ # CI 工作流
56
+ ├── pyproject.toml # 项目配置
57
+ └── README.md # 项目文档
58
+ ```
59
+
60
+ ## 开发
61
+
62
+ ### 安装开发依赖
63
+
64
+ ```bash
65
+ uv sync --all-extras
66
+ ```
67
+
68
+ ### 运行测试
69
+
70
+ ```bash
71
+ uv run pytest
72
+ ```
73
+
74
+ ### 代码检查
75
+
76
+ ```bash
77
+ uv run ruff check # 代码风格检查
78
+ uv run ty check # 类型检查
79
+ uv run pytest # 样例测试
80
+ ```
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "xutk"
3
+ version = "0.3.4"
4
+ description = "Xulab toolkit in python"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.15"
7
+ dependencies = [
8
+ "colorama>=0.4.6",
9
+ "psutil>=7.1.0",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest>=8.4.1",
15
+ "ruff>=0.12.4",
16
+ "ty>=0.0.1a14",
17
+ ]
18
+
19
+ # [[tool.uv.index]]
20
+ # name = "gitea-zelin2001"
21
+ # url = "http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple"
22
+ # publish-url = "http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi"
23
+
xutk-0.3.4/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """Xulab Useful Toolkits for Python.
2
+
3
+ - module `log`:
4
+ - class CtxLogger
5
+ - set_log_level()
6
+ - module `mp_runner`:
7
+ - class MPRunner
8
+ - class ThreadPoolRunner
9
+ - dataclass SLURMConfig
10
+ - class SLURMRunner
11
+ - mprunner_factory()
12
+ - module `perf`:
13
+ - class Perf
14
+ - fadvise_remove_cache()
15
+ - module `verchk`:
16
+ - check_version()
17
+
18
+ """
xutk-0.3.4/xutk/log.py ADDED
@@ -0,0 +1,175 @@
1
+ """Logging for A package with level-specific, contex aware messages.
2
+
3
+ - Use `XUTK_LOG_LEVEL` environment variable to set logging level
4
+ level: One of 'DEBUG', 'INFO', 'ARNING', 'ERROR'
5
+ - Alternatively, use function `set_log_level()`
6
+ - Use `CtxLogger` to initialize specific logger
7
+ """
8
+
9
+ import colorama
10
+ import logging
11
+ import os
12
+ import sys
13
+ from typing import Dict, Any
14
+
15
+
16
+ class _ColorfulFormatter(logging.Formatter):
17
+ """A custom logging formatter that adds color to log messages.
18
+
19
+ This formatter uses colorama to add ANSI color codes to log messages
20
+ based on their severity level, making them easier to distinguish
21
+ in terminal output.
22
+ """
23
+
24
+ def formatMessage(self, record: logging.LogRecord) -> str:
25
+ """Format the message part of the log record with color.
26
+
27
+ This method is called by the parent format() method and only
28
+ adds color to the levelname while preserving all other standard
29
+ formatting behavior.
30
+
31
+ Args:
32
+ record: The log record to format
33
+
34
+ Returns:
35
+ A formatted string with colored level name
36
+ """
37
+ # Color mapping for log levels
38
+ log_colors = {
39
+ "NOTSET": colorama.Fore.WHITE,
40
+ "DEBUG": colorama.Fore.CYAN,
41
+ "INFO": colorama.Fore.GREEN,
42
+ "WARNING": colorama.Fore.YELLOW + colorama.Style.BRIGHT,
43
+ "ERROR": colorama.Fore.RED + colorama.Style.BRIGHT,
44
+ "CRITICAL": colorama.Fore.RED + colorama.Style.BRIGHT,
45
+ }
46
+
47
+ # Passing colored levelname to parent's formatMessage
48
+ original_levelname = record.levelname
49
+ record.levelname = (
50
+ f"{log_colors.get(original_levelname, '')}"
51
+ + f"{original_levelname}{colorama.Style.RESET_ALL}"
52
+ )
53
+ try:
54
+ # Use parent's formatMessage to handle the actual formatting
55
+ result = super().formatMessage(record)
56
+ finally:
57
+ # Restore original levelname
58
+ record.levelname = original_levelname
59
+
60
+ return result
61
+
62
+
63
+ class CtxLogger:
64
+ """Logger with built-in context formatting and level methods."""
65
+
66
+ caller_names: set[str]
67
+ _logger: logging.Logger
68
+ _color_flag: bool
69
+
70
+ def __init__(self, name: str = "xutk") -> None:
71
+ """Initialize the CtxLogger with basic configuration.
72
+
73
+ Args:
74
+ name: Logger name (defaults to 'xutk'). Creates a new logger or returns
75
+ existing one if name matches.
76
+
77
+ Configures logging level from XUTK_LOG_LEVEL (PKG_LOG_LEVEL)
78
+ environment variable or set_log_level() (defaults to INFO)
79
+ and sets up a basic console handler with standard formatting.
80
+
81
+ Also override color output setting by environment variable
82
+ XUTK_LOG_COLOR (PKG_LOG_COLOR), True (true) or False (false).
83
+ """
84
+ self._logger = logging.getLogger(name)
85
+ self.caller_names = {"caller", "plotter", "loader", "processor"}
86
+ self._init_handler_and_color(name)
87
+
88
+ def _init_handler_and_color(self, name: str) -> None:
89
+ color_opt = os.getenv(name.upper() + "_LOG_COLOR", "auto").lower()
90
+ color_flag = False
91
+ if "auto" == color_opt:
92
+ color_flag = sys.stdout.isatty()
93
+ elif color_opt in {"true", "1", "yes", "y"}:
94
+ color_flag = True
95
+ if color_flag:
96
+ colorama.init()
97
+ self._color_flag = color_flag
98
+
99
+ if not self._logger.handlers:
100
+ # Set level from environment or default to INFO
101
+ logger_level = name.upper() + "_LOG_LEVEL"
102
+ level = os.getenv(logger_level, "INFO").upper()
103
+ self._logger.setLevel(getattr(logging, level, logging.INFO))
104
+
105
+ handler = logging.StreamHandler()
106
+ formatter = (
107
+ _ColorfulFormatter(
108
+ "%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
109
+ )
110
+ if self._color_flag
111
+ else logging.Formatter(
112
+ "%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
113
+ )
114
+ )
115
+ handler.setFormatter(formatter)
116
+ self._logger.addHandler(handler)
117
+
118
+ def _log_with_context(
119
+ self, level: int, context: Dict[str, Any], message: str
120
+ ) -> None:
121
+ """Add to log internally."""
122
+ callers = [(k, v) for k, v in context.items() if k in self.caller_names]
123
+ caller_str = ""
124
+ try:
125
+ caller_str = callers[0][1]
126
+ except IndexError:
127
+ pass
128
+
129
+ if self._color_flag:
130
+ message = (
131
+ str(colorama.Style.BRIGHT) + message + str(colorama.Style.RESET_ALL)
132
+ )
133
+
134
+ ctx_items = [
135
+ f"{k}: {v}" for k, v in context.items() if k not in self.caller_names
136
+ ]
137
+ ctx_str = "; ".join(ctx_items)
138
+
139
+ self._logger.log(
140
+ level,
141
+ f"{caller_str}: {message}"
142
+ + f"{' | ' if len(ctx_str) > 0 else ''}{ctx_str}",
143
+ )
144
+
145
+ def debug(self, context: Dict[str, Any], message: str) -> None:
146
+ """Log debug message with context."""
147
+ self._log_with_context(logging.DEBUG, context, message)
148
+
149
+ def info(self, context: Dict[str, Any], message: str) -> None:
150
+ """Log info message with context."""
151
+ self._log_with_context(logging.INFO, context, message)
152
+
153
+ def warning(self, context: Dict[str, Any], message: str) -> None:
154
+ """Log warning message with context."""
155
+ self._log_with_context(logging.WARNING, context, message)
156
+
157
+ def error(self, context: Dict[str, Any], message: str) -> None:
158
+ """Log error message with context."""
159
+ self._log_with_context(logging.ERROR, context, message)
160
+
161
+ def get_log_level(self) -> int:
162
+ """Get log level (number)."""
163
+ return self._logger.getEffectiveLevel()
164
+
165
+
166
+ def set_log_level(level: str, logger_name: str = "xutk") -> None:
167
+ """Set log level programmatically.
168
+
169
+ Args:
170
+ level: One of 'DEBUG', 'INFO', 'WARNING', 'ERROR'
171
+ (Neither 'NOTSET' nor 'CRITICAL' are provided)
172
+ logger_name: Name of the logger to configure (defaults to 'xutk')
173
+ """
174
+ logger = logging.getLogger(logger_name)
175
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
@@ -0,0 +1,464 @@
1
+ """Parallel computing runner module.
2
+
3
+ This module provides a flexible interface for executing tasks
4
+ using different parallel computing backends. It implements a factory pattern
5
+ through mprunner_factory that creates appropriate runners based on the client
6
+ type provided.
7
+
8
+ The module supports multiple execution modes:
9
+ 1. ThreadPoolExecutor: For local multi-threaded processing
10
+ 2. SLURMConfig: For job submission to SLURM clusters
11
+ 3. DaskClient (future): For distributed computing with Dask
12
+
13
+ Key Components:
14
+ - MPRunner: Abstract base class defining the interface for all runners
15
+ - ThreadPoolRunner: Implementation for local thread-based parallelism
16
+ - SLURMRunner: Implementation for SLURM cluster job submission
17
+ - SLURMConfig: Configuration class for SLURM job parameters
18
+ - mprunner_factory: Factory function to create appropriate runner instances
19
+ - MPClient: Type alias for Union[ThreadPoolExecutor, SLURMConfig]
20
+
21
+ The API provides two main execution methods:
22
+ - batch_run: For executing command-line operations
23
+ - batch_function_run: For executing Python functions
24
+
25
+ Each runner implementation handles the execution details while providing
26
+ a consistent interface. The abstract methods _submit_command, _submit_function,
27
+ _join_futures, and _cancel_futures provide extension points for new runners.
28
+
29
+ Usage:
30
+ ```python
31
+ # For local processing with limited workers
32
+ executor = ThreadPoolExecutor(max_workers=4)
33
+ runner = mprunner_factory(executor, log_file)
34
+ runner.batch_run(cmd_args_list)
35
+ runner.batch_function_run(process_video, video_paths, presets)
36
+
37
+ # For SLURM cluster processing
38
+ config = SLURMConfig(partition="batch", num_cpus=12, memory="8G", output=log_file)
39
+ runner = mprunner_factory(config, log_file)
40
+ runner.batch_run(cmd_args_list)
41
+
42
+ # For future Dask distributed processing
43
+ client = Client("scheduler-address:8786")
44
+ runner = mprunner_factory(client, log_file)
45
+ runner.batch_function_run(process_video, video_paths, presets)
46
+
47
+ # Using the MPClient type alias for better type hints
48
+ def process_videos(client: MPClient, videos: list[str]) -> None:
49
+ runner = mprunner_factory(client)
50
+ runner.batch_run(create_ffmpeg_commands(videos))
51
+ ```
52
+ """
53
+
54
+ from abc import ABC, abstractmethod
55
+ from dataclasses import dataclass
56
+ from concurrent.futures import Future, ThreadPoolExecutor
57
+ from pathlib import Path
58
+ import queue
59
+ import shlex
60
+ from subprocess import run, CompletedProcess
61
+ import threading
62
+ from typing import Callable
63
+
64
+ from xutk.log import CtxLogger as Logger
65
+
66
+ vidtidy_logger = Logger("vidtidy")
67
+
68
+
69
+ class MPRunner(ABC):
70
+ """Handles video processing with string parameters."""
71
+
72
+ def __init__(self, client, log_file: Path | None) -> None: # noqa: ANN001
73
+ """Initialize with basic file patterns and optional config.
74
+
75
+ Args:
76
+ client: Configuration or executor for the runner
77
+ log_file: Path to the log file (optional, if None logging is disabled)
78
+
79
+ """
80
+ self._log_queue = queue.Queue()
81
+ self._log_lock = threading.Lock()
82
+
83
+ if log_file:
84
+ self._log_file = log_file
85
+ self._log_thread = threading.Thread(target=self._log_writer, daemon=True)
86
+ self._log_thread.start()
87
+ else:
88
+ self._log_file = Path()
89
+ self._log_thread = None
90
+
91
+ self.client = client
92
+
93
+ def _log_writer(self) -> None:
94
+ """Background thread that writes logs sequentially.
95
+
96
+ This method runs as a daemon thread, continuously processing log messages
97
+ from the queue and writing them to the log file. Only active when log_file
98
+ is not None.
99
+ """
100
+ if not self._log_file:
101
+ return
102
+
103
+ while True:
104
+ log_msg = self._log_queue.get()
105
+ try:
106
+ with self._log_file.open("a") as f:
107
+ f.write(log_msg)
108
+ except Exception as e:
109
+ vidtidy_logger.error(
110
+ {"caller": self.__class__.__name__}, f"Failed to write log: {e}"
111
+ )
112
+ self._log_queue.task_done()
113
+
114
+ @abstractmethod
115
+ def batch_run(self, cmd_args_list: list[list[str]]) -> list:
116
+ """Run multiple commands synchronously, blocking until completion.
117
+
118
+ Args:
119
+ cmd_args_list: List of command argument lists to execute
120
+
121
+ """
122
+ ...
123
+
124
+ @abstractmethod
125
+ def batch_function_run(
126
+ self,
127
+ func: Callable,
128
+ *args_lists: list,
129
+ **common_kwargs, # noqa: ANN003
130
+ ) -> list:
131
+ """Run multiple instances of a function together, blocking until completion.
132
+
133
+ Supports two calling conventions:
134
+ 1. Multiple argument lists: batch_function_run
135
+ (func, arg1_list, arg2_list, kwarg=value)
136
+ 2. List of argument tuples: batch_function_run
137
+ (func, [(arg1, arg2), ...], kwarg=value)
138
+
139
+ Args:
140
+ func: The function to execute
141
+ *args_lists: Variable length argument lists
142
+ **common_kwargs: Common keyword arguments for all function calls
143
+
144
+ """
145
+ ...
146
+
147
+
148
+ class ThreadPoolRunner(MPRunner):
149
+ """Handles video processing with string parameters."""
150
+
151
+ client: ThreadPoolExecutor
152
+
153
+ def batch_run(self, cmd_args_list: list[list[str]]) -> list[CompletedProcess[str]]: # noqa: D102
154
+ futures: list[Future[CompletedProcess[str]]] = []
155
+ results: list[CompletedProcess[str]] = []
156
+ try:
157
+ with self.client as executor:
158
+ for cmd_item in cmd_args_list:
159
+ future = executor.submit(self._thread_pool_execute, cmd_item)
160
+ futures.append(future)
161
+ # Wait for all futures and logs to complete
162
+ try:
163
+ results.extend(future.result() for future in futures)
164
+ self._log_queue.join()
165
+ except KeyboardInterrupt:
166
+ vidtidy_logger.warning(
167
+ {"caller": self.__class__.__name__},
168
+ "\nProcessing interrupted by user. Cleaning up...",
169
+ )
170
+ # Cancel all pending futures on interrupt
171
+ for future in futures:
172
+ future.cancel()
173
+ # Wait for any running tasks to complete
174
+ for future in futures:
175
+ if not future.done():
176
+ future.result()
177
+ self._log_queue.join()
178
+ raise
179
+ except KeyboardInterrupt:
180
+ vidtidy_logger.warning(
181
+ {"caller": self.__class__.__name__},
182
+ "\nJob scheduling interrupted by user. Cleaning up...",
183
+ )
184
+ raise
185
+ return results
186
+
187
+ def _thread_pool_execute(self, cmd_args: list[str]) -> CompletedProcess[str]:
188
+ process = run(cmd_args, check=True, capture_output=True, text=True)
189
+ with self._log_lock:
190
+ # FFmpeg uses stderr for normal output and stdout for progress
191
+ # Use the last argument as the processing file if available,
192
+ # otherwise use command name
193
+ target_file = cmd_args[-1] if len(cmd_args) > 1 else cmd_args[0]
194
+ self._log_queue.put(
195
+ f"=== Processing {target_file} ===\n"
196
+ f"Output:\n{process.stdout}\n"
197
+ f"Errors:\n{process.stderr}\n\n"
198
+ )
199
+ return process
200
+
201
+ def batch_function_run( # noqa: D102
202
+ self,
203
+ func: Callable,
204
+ *args_lists: list,
205
+ **common_kwargs, # noqa: ANN003
206
+ ) -> list:
207
+ if not args_lists:
208
+ vidtidy_logger.warning(
209
+ {"caller": self.__class__.__name__},
210
+ "\nBatch runner got args_list with different length.",
211
+ )
212
+ return []
213
+
214
+ arg_list_tuple = self._batch_function_parse_args(args_lists)
215
+
216
+ # === Build Tasks and Submit ===
217
+ futures: list[Future] = []
218
+ results: list = []
219
+ try:
220
+ with self.client as executor:
221
+ for args in arg_list_tuple:
222
+ future = executor.submit(func, *args, **common_kwargs)
223
+ futures.append(future)
224
+
225
+ # Wait for completion and collect results in order
226
+ try:
227
+ # Wait for all futures and collect results in original order
228
+ results.extend(future.result() for future in futures)
229
+ self._log_queue.join()
230
+ except KeyboardInterrupt:
231
+ vidtidy_logger.warning(
232
+ {"caller": self.__class__.__name__},
233
+ "\nProcessing interrupted by user. Cancelling tasks...",
234
+ )
235
+ for future in futures:
236
+ future.cancel()
237
+ # Allow running tasks to finish cleanly
238
+ for future in futures:
239
+ if not future.done():
240
+ try:
241
+ future.result()
242
+ except: # noqa: E722
243
+ pass
244
+ self._log_queue.join()
245
+ raise
246
+
247
+ return results
248
+
249
+ except KeyboardInterrupt:
250
+ vidtidy_logger.warning(
251
+ {"caller": self.__class__.__name__},
252
+ "\nJob Scheduling interrupted by user. Cleaning up...",
253
+ )
254
+ raise
255
+
256
+ @staticmethod
257
+ def _batch_function_parse_args(args_lists: tuple[list, ...]) -> list[tuple]:
258
+ """Parse input arguments into list of argument tuples.
259
+
260
+ Supports two calling conventions:
261
+ 1. Multiple argument lists: [a1,a2], [b1,b2] -> [(a1,b1), (a2,b2)]
262
+ 2. List of tuples: [(a1,b1), (a2,b2)] -> [(a1,b1), (a2,b2)]
263
+
264
+ Args:
265
+ args_lists: Tuple of argument lists (from *args)
266
+
267
+ Returns:
268
+ List of argument tuples for function calls
269
+
270
+ Raises:
271
+ ValueError: If argument lists have different lengths or invalid format
272
+
273
+ """
274
+ if not args_lists:
275
+ return []
276
+
277
+ first_arg = args_lists[0]
278
+
279
+ if len(args_lists) == 1 and isinstance(first_arg, list) and first_arg:
280
+ # Case 1: Single list of tuples - [(arg1, arg2), ...]
281
+ if isinstance(first_arg[0], tuple):
282
+ return first_arg # Already in (arg1, arg2) format
283
+ else:
284
+ # Case 2: Single list of non-tuples, treat as single argument
285
+ return [(x,) for x in first_arg]
286
+ else:
287
+ # Case 3: Multiple argument lists - [a1,a2], [b1,b2]
288
+ n_args = len(args_lists[0])
289
+ if any(len(lst) != n_args for lst in args_lists):
290
+ raise ValueError("All argument lists must have the same length.")
291
+ return list(zip(*args_lists))
292
+
293
+
294
+ @dataclass
295
+ class SLURMConfig:
296
+ """Configuration for SLURM job submission.
297
+
298
+ Attributes:
299
+ output: Path for stdout log file
300
+ error: Path for stderr log file
301
+ partition: SLURM partition to use
302
+ node: Specific node to run on (optional)
303
+ num_cpus: Number of CPU cores to allocate per task
304
+ memory: Memory allocation in GB
305
+ job_name: Name for the SLURM job
306
+ time_limit: Time limit for the job (HH:MM:SS format)
307
+
308
+ """
309
+
310
+ output: Path | None = None
311
+ error: Path | None = None
312
+ partition: str = "batch"
313
+ node: str | None = None
314
+ num_cpus: int = 12
315
+ memory: str = "8G"
316
+ job_name: str = "vidtidy_job"
317
+ time_limit: str = "02:00:00"
318
+
319
+
320
+ class SLURMRunner(MPRunner):
321
+ """Handles SLURM job submission for video processing tasks."""
322
+
323
+ client: SLURMConfig
324
+
325
+ def batch_run(self, cmd_args_list: list[list[str]]) -> list[int]:
326
+ """Submit multiple commands as SLURM jobs using the client configuration.
327
+
328
+ Args:
329
+ cmd_args_list: List of command argument lists to execute
330
+
331
+ Raises:
332
+ RuntimeError: If any job submission fails
333
+
334
+ """
335
+ job_ids: list[int] = []
336
+ try:
337
+ for i, cmd_args in enumerate(cmd_args_list):
338
+ # Create unique job name for each task
339
+ job_name = f"{self.client.job_name}_{i}"
340
+
341
+ # Build sbatch command with options from config
342
+ sbatch_cmd = [
343
+ "sbatch",
344
+ "--job-name",
345
+ job_name,
346
+ "--time",
347
+ self.client.time_limit,
348
+ "--partition",
349
+ self.client.partition,
350
+ "--cpus-per-task",
351
+ str(self.client.num_cpus),
352
+ "--mem",
353
+ self.client.memory,
354
+ ]
355
+
356
+ # Add node specification if provided
357
+ if self.client.node:
358
+ sbatch_cmd.extend(["--nodelist", self.client.node])
359
+
360
+ # Add output and error log paths if specified
361
+ if self.client.output:
362
+ sbatch_cmd.extend(
363
+ [
364
+ "--output",
365
+ str(
366
+ self.client.output.with_suffix(
367
+ f".{i}{self.client.output.suffix}"
368
+ )
369
+ ),
370
+ ]
371
+ )
372
+ if self.client.error:
373
+ sbatch_cmd.extend(
374
+ [
375
+ "--error",
376
+ str(
377
+ self.client.error.with_suffix(
378
+ f".{i}{self.client.error.suffix}"
379
+ )
380
+ ),
381
+ ]
382
+ )
383
+
384
+ # Add the actual command to run
385
+ sbatch_cmd.extend(["--wrap", shlex.join(cmd_args)])
386
+
387
+ # Submit the job
388
+ result = run(sbatch_cmd, capture_output=True, text=True, check=True)
389
+
390
+ if result.returncode != 0:
391
+ raise RuntimeError(f"SLURM job submission failed: {result.stderr}")
392
+
393
+ # Extract job ID from output (format: "Submitted batch job <job_id>")
394
+ job_id_line = result.stdout.strip()
395
+ if "Submitted batch job" in job_id_line:
396
+ job_id = int(job_id_line.split()[-1])
397
+ job_ids.append(job_id)
398
+ self._log_queue.put(
399
+ f"Submitted SLURM job {job_id} for "
400
+ + f"command: {shlex.join(cmd_args)}\n"
401
+ )
402
+ else:
403
+ raise RuntimeError(
404
+ f"Could not parse SLURM job ID from: {job_id_line}"
405
+ )
406
+
407
+ except Exception:
408
+ # Cancel all submitted jobs if there's an error
409
+ for job_id in job_ids:
410
+ run(["scancel", str(job_id)], capture_output=True, check=True)
411
+ raise
412
+
413
+ # Wait for all jobs to complete and logs to be written
414
+ try:
415
+ self._log_queue.join()
416
+ except KeyboardInterrupt:
417
+ # Cancel all jobs on interrupt
418
+ for job_id in job_ids:
419
+ run(["scancel", str(job_id)], capture_output=True, check=True)
420
+ self._log_queue.join()
421
+ raise
422
+
423
+ return job_ids
424
+
425
+ def batch_function_run( # noqa: D102
426
+ self,
427
+ func: Callable,
428
+ *args_lists: list,
429
+ **common_kwargs, # noqa: ANN003
430
+ ) -> None:
431
+ raise NotImplementedError(
432
+ """
433
+ SLURMRunner does not support function execution.
434
+ Use batch_run with shell commands.
435
+ """
436
+ )
437
+
438
+
439
+ MPClient = int | ThreadPoolExecutor | SLURMConfig
440
+
441
+
442
+ def mprunner_factory(mp_client: MPClient, log_file: Path | None = None) -> MPRunner:
443
+ """Create appropriate runner based on client type.
444
+
445
+ Args:
446
+ mp_client: A multi-processing implement instance
447
+ log_file: Path to the log file
448
+
449
+ Returns:
450
+ Appropriate runner instance
451
+ (ThreadPoolRunner, SLURMRunner, or future DaskRunner)
452
+
453
+ Raises:
454
+ NotImplementedError: If client type is not supported
455
+
456
+ """
457
+ if isinstance(mp_client, int) and mp_client > 0:
458
+ return ThreadPoolRunner(ThreadPoolExecutor(max_workers=mp_client), log_file)
459
+ elif isinstance(mp_client, ThreadPoolExecutor):
460
+ return ThreadPoolRunner(mp_client, log_file)
461
+ if isinstance(mp_client, SLURMConfig):
462
+ return SLURMRunner(mp_client, log_file)
463
+ else:
464
+ raise NotImplementedError(f"Unsupported client type: {type(mp_client)}")
@@ -0,0 +1,89 @@
1
+ """Performance measurement utilities.
2
+
3
+ This module provides tools for measuring and analyzing code performance,
4
+ including execution time and memory usage.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ import tracemalloc
10
+ import psutil
11
+ from contextlib import ContextDecorator
12
+ from pathlib import Path
13
+
14
+
15
+ class Perf(ContextDecorator):
16
+ """Performance measurement context manager and decorator.
17
+
18
+ Measures wall time, CPU time, memory usage and allocations.
19
+ """
20
+
21
+ def __init__(self, label: str | None = None, print_output: bool = True) -> None:
22
+ """Initialize performance measurement context.
23
+
24
+ Args:
25
+ label: Optional label for the measurement
26
+ print_output: Whether to print results automatically
27
+
28
+ """
29
+ self.label = label
30
+ self.cpu = 0
31
+ self.wall = 0
32
+ self.ratio = 0
33
+ self.rss = 0
34
+ self.peak = 0
35
+ self.final_rss = 0
36
+ self.print = print_output
37
+
38
+ self.label_str = ""
39
+
40
+ def __enter__(self) -> "Perf":
41
+ """Enter the performance measurement context.
42
+
43
+ Returns:
44
+ The Perf instance itself
45
+
46
+ """
47
+ self.proc = psutil.Process(os.getpid())
48
+ self.t0_cpu = time.process_time()
49
+ self.t0_wall = time.time()
50
+ self.t0_rss = self.proc.memory_info().rss
51
+ tracemalloc.start()
52
+ return self
53
+
54
+ def __exit__(self, *exc) -> None: # noqa: ANN002
55
+ """Exit the performance measurement context.
56
+
57
+ Args:
58
+ *exc: Exception information if any
59
+
60
+ """
61
+ self.cpu = time.process_time() - self.t0_cpu
62
+ self.wall = time.time() - self.t0_wall
63
+ self.ratio = self.cpu / self.wall if self.wall > 0 else 0
64
+ self.rss = self.proc.memory_info().rss - self.t0_rss
65
+ self.peak = tracemalloc.get_traced_memory()[1] / 1_048_576
66
+ self.final_rss = self.proc.memory_info().rss / 1_048_576
67
+ tracemalloc.stop()
68
+
69
+ self.label_str = f"[{self.label}]" if self.label is not None else ""
70
+ self.label_str += f"\tWall Time: {self.wall:.3f}s"
71
+ self.label_str += f"\tCPU Time: {self.cpu:.3f}s({self.ratio:.2f}x)"
72
+ self.label_str += f"\tΔRSS: {self.rss / 1_048_576:.1f} MiB"
73
+ self.label_str += f"\tPeak alloc: {self.peak:.1f} MiB"
74
+ self.label_str += f"\tFinal RSS: {self.final_rss:.1f} MiB"
75
+
76
+ if self.print:
77
+ print(self.label_str)
78
+
79
+
80
+ def fadvise_remove_cache(path: str) -> None:
81
+ """Advise OS to remove file from cache.
82
+
83
+ Args:
84
+ path: Path to the file to remove from cache
85
+
86
+ """
87
+ with Path(path).open("rb") as f:
88
+ fd = f.fileno()
89
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
@@ -0,0 +1,34 @@
1
+ """Manage packages and versions."""
2
+
3
+ from importlib import metadata
4
+ import packaging.version as pv
5
+
6
+
7
+ def check_version(version_need: str, package_name: str) -> None:
8
+ """Check if the installed version of a package satisfies the required version.
9
+
10
+ Args:
11
+ version_need (str): The minimum required version string (e.g., '0.3.0a1').
12
+ package_name (str): The name of the package to check (e.g., 'plotma').
13
+
14
+ Raises:
15
+ ImportError: If the package is not installed or the version is incompatible.
16
+
17
+ """
18
+ # Get versions: required, installed
19
+ required = pv.parse(version_need)
20
+ try:
21
+ installed = pv.parse(metadata.version(package_name))
22
+ except metadata.PackageNotFoundError:
23
+ raise ImportError(f"错误: {package_name} 包未安装。")
24
+
25
+ # Check version compatibility
26
+ if installed >= required: # satisfied minimum version required
27
+ if installed.release[0] == required.release[0]: # satisfied MAJOR
28
+ if required.release[0] > 0 or installed.release[1] == required.release[1]:
29
+ # version compatible
30
+ return
31
+ raise ImportError(
32
+ f"版本不兼容:需要 {package_name} = {version_need}, 当前为 {installed}"
33
+ )
34
+ return
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: xutk
3
+ Version: 0.3.4
4
+ Summary: Xulab toolkit in python
5
+ Requires-Python: <3.15,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: colorama>=0.4.6
8
+ Requires-Dist: psutil>=7.1.0
9
+
10
+ # Xulab Useful Toolkit in Python (xutk)
11
+
12
+ ![Maintenance](https://img.shields.io/maintenance/Zelin2001/2025)
13
+ ![PyPI - Python Version](https://img.shields.io/badge/python-3.11|3.12|3.13|3.14|3.14t-blue.svg)
14
+
15
+ ## 导入项目
16
+
17
+ ```bash
18
+ pip install \
19
+ --trusted-host "gitea.xulab.ion.ac.cn" \
20
+ --extra-index-url "http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple/" \
21
+ xutk
22
+ ```
23
+
24
+ 或者使用 uv:
25
+
26
+ ```bash
27
+ uv add --index http://gitea.xulab.ion.ac.cn/api/packages/zelin2001/pypi/simple xutk
28
+ ```
29
+
30
+ ## 快速开始
31
+
32
+ ```python
33
+ from xutk import verchk, log, mp_runner
34
+ from pathlib import Path
35
+
36
+ # 版本检查
37
+ verchk.check_version("0.2.0", "some_package")
38
+
39
+ # 日志记录
40
+ logger = log.CtxLogger("my_app")
41
+ logger.info({"module": "processor", "file": "data.csv"}, "Processing started")
42
+
43
+ # 多进程运行器
44
+ runner = mp_runner.mprunner_factory(2, log_file=Path("process.log")) # 进程队列,取 2 个运行
45
+ runner.batch_run(["echo", "-E", f"test{num}"] for num in range(1,25)) # 排队 25 个进程
46
+ ```
47
+
48
+ **建议在项目 `__init__.py` 创建 CtxLogger("my_app"),避免实例化顺序问题。**
49
+
50
+ ## 项目结构
51
+
52
+ ```text
53
+ xutk/
54
+ ├── xutk/
55
+ │ ├── __init__.py # 包初始化
56
+ │ ├── log.py # 日志记录功能
57
+ │ ├── mp_runner.py # 多进程运行管理
58
+ │ ├── perf.py # 快速资源检查
59
+ │ └── verchk.py # 版本检查功能
60
+ ├── test/
61
+ │ ├── integration/ # 集成测试
62
+ │ └── unit/ # 单元测试
63
+ ├── gitea/
64
+ │ └── workflows/ # CI 工作流
65
+ ├── pyproject.toml # 项目配置
66
+ └── README.md # 项目文档
67
+ ```
68
+
69
+ ## 开发
70
+
71
+ ### 安装开发依赖
72
+
73
+ ```bash
74
+ uv sync --all-extras
75
+ ```
76
+
77
+ ### 运行测试
78
+
79
+ ```bash
80
+ uv run pytest
81
+ ```
82
+
83
+ ### 代码检查
84
+
85
+ ```bash
86
+ uv run ruff check # 代码风格检查
87
+ uv run ty check # 类型检查
88
+ uv run pytest # 样例测试
89
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ xutk/__init__.py
4
+ xutk/log.py
5
+ xutk/mp_runner.py
6
+ xutk/perf.py
7
+ xutk/verchk.py
8
+ xutk.egg-info/PKG-INFO
9
+ xutk.egg-info/SOURCES.txt
10
+ xutk.egg-info/dependency_links.txt
11
+ xutk.egg-info/requires.txt
12
+ xutk.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ colorama>=0.4.6
2
+ psutil>=7.1.0
@@ -0,0 +1 @@
1
+ xutk