pepeunit-client 0.10.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.
@@ -0,0 +1,11 @@
1
+ """
2
+ PepeunitClient - Мультиплатформенная библиотека для работы с Pepeunit Unit Storage
3
+ """
4
+
5
+ __version__ = "0.10.0"
6
+
7
+ from .client import PepeunitClient
8
+ from .abstract_clients import AbstractPepeunitMqttClient, AbstractPepeunitRestClient
9
+ from .enums import RestartMode
10
+
11
+ __all__ = ['PepeunitClient', 'AbstractPepeunitMqttClient', 'AbstractPepeunitRestClient', 'RestartMode']
@@ -0,0 +1,156 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional, Callable, List, Dict, Any, TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from .settings import Settings
6
+ from .schema_manager import SchemaManager
7
+ from .logger import Logger
8
+
9
+
10
+ class AbstractPepeunitMqttClient(ABC):
11
+ """
12
+ Abstract base class for Pepeunit MQTT clients.
13
+
14
+ Users can inherit from this class to create custom MQTT clients
15
+ that integrate with the Pepeunit ecosystem.
16
+ """
17
+
18
+ def __init__(self, settings: 'Settings', schema_manager: 'SchemaManager', logger: 'Logger'):
19
+ self.settings = settings
20
+ self.schema_manager = schema_manager
21
+ self.logger = logger
22
+
23
+ @abstractmethod
24
+ def connect(self) -> None:
25
+ """Connect to MQTT broker using settings configuration."""
26
+ pass
27
+
28
+ @abstractmethod
29
+ def disconnect(self) -> None:
30
+ """Disconnect from MQTT broker."""
31
+ pass
32
+
33
+ @abstractmethod
34
+ def subscribe_topics(self, topics: List[str]) -> None:
35
+ """
36
+ Subscribe to specific MQTT topics.
37
+
38
+ Args:
39
+ topics: List of topic strings to subscribe to
40
+ """
41
+ pass
42
+
43
+ @abstractmethod
44
+ def publish(self, topic: str, message: str) -> None:
45
+ """
46
+ Publish message to specific topic.
47
+
48
+ Args:
49
+ topic: MQTT topic to publish to
50
+ message: Message content to publish
51
+ """
52
+ pass
53
+
54
+ @abstractmethod
55
+ def set_input_handler(self, handler: Callable) -> None:
56
+ """
57
+ Set user-defined input message handler.
58
+
59
+ Args:
60
+ handler: Function to handle incoming MQTT messages
61
+ """
62
+ pass
63
+
64
+
65
+ class AbstractPepeunitRestClient(ABC):
66
+ """
67
+ Abstract base class for Pepeunit REST clients.
68
+
69
+ Users can inherit from this class to create custom REST clients
70
+ that integrate with the Pepeunit API.
71
+ """
72
+
73
+ def __init__(self, settings: 'Settings'):
74
+ self.settings = settings
75
+
76
+ @abstractmethod
77
+ def download_update(self, file_path: str) -> None:
78
+ """
79
+ Download firmware update archive.
80
+
81
+ Args:
82
+ unit_uuid: UUID of the unit to download update for
83
+ file_path: Local path where to save the downloaded file
84
+ """
85
+ pass
86
+
87
+ @abstractmethod
88
+ def download_env(self, file_path: str) -> None:
89
+ """
90
+ Download env.json configuration.
91
+
92
+ Args:
93
+ unit_uuid: UUID of the unit to download config for
94
+ file_path: Local path where to save the env.json file
95
+ """
96
+ pass
97
+
98
+ @abstractmethod
99
+ def download_schema(self, file_path: str) -> None:
100
+ """
101
+ Download schema.json configuration.
102
+
103
+ Args:
104
+ unit_uuid: UUID of the unit to download schema for
105
+ file_path: Local path where to save the schema.json file
106
+ """
107
+ pass
108
+
109
+ @abstractmethod
110
+ def set_state_storage(self, state: Dict[str, Any]) -> None:
111
+ """
112
+ Store state data in Pepeunit Unit Storage.
113
+
114
+ Args:
115
+ unit_uuid: UUID of the unit
116
+ state: State data to store
117
+ """
118
+ pass
119
+
120
+ @abstractmethod
121
+ def get_state_storage(self, unit_uuid: str) -> Dict[str, Any]:
122
+ """
123
+ Retrieve state data from Pepeunit Unit Storage.
124
+
125
+ Args:
126
+ unit_uuid: UUID of the unit
127
+
128
+ Returns:
129
+ Retrieved state data
130
+ """
131
+ pass
132
+
133
+ def _get_auth_headers(self) -> Dict[str, str]:
134
+ """
135
+ Get authentication headers for API requests.
136
+
137
+ Users can override this if they need custom authentication.
138
+
139
+ Returns:
140
+ Dictionary with authentication headers
141
+ """
142
+ return {
143
+ 'accept': 'application/json',
144
+ 'x-auth-token': self.settings.PEPEUNIT_TOKEN,
145
+ }
146
+
147
+ def _get_base_url(self) -> str:
148
+ """
149
+ Get base URL for Pepeunit API.
150
+
151
+ Users can override this if they need custom URL construction.
152
+
153
+ Returns:
154
+ Base URL string
155
+ """
156
+ return f"{self.settings.HTTP_TYPE}://{self.settings.PEPEUNIT_URL}{self.settings.PEPEUNIT_APP_PREFIX}{self.settings.PEPEUNIT_API_ACTUAL_PREFIX}"
@@ -0,0 +1,308 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import time
5
+ import sys
6
+ import subprocess
7
+ from typing import Optional, Dict, Any, Callable
8
+
9
+ # Import for mocking in tests
10
+ try:
11
+ import psutil
12
+ except ImportError:
13
+ psutil = None
14
+
15
+ from .settings import Settings
16
+ from .file_manager import FileManager
17
+ from .logger import Logger
18
+ from .schema_manager import SchemaManager
19
+ from .abstract_clients import AbstractPepeunitMqttClient, AbstractPepeunitRestClient
20
+ from .pepeunit_mqtt_client import PepeunitMqttClient
21
+ from .pepeunit_rest_client import PepeunitRestClient
22
+ from .enums import BaseInputTopicType, BaseOutputTopicType, RestartMode
23
+
24
+
25
+ class PepeunitClient:
26
+ def __init__(
27
+ self,
28
+ env_file_path: str,
29
+ schema_file_path: str,
30
+ log_file_path: str,
31
+ enable_mqtt: bool = False,
32
+ enable_rest: bool = False,
33
+ mqtt_client: Optional[AbstractPepeunitMqttClient] = None,
34
+ rest_client: Optional[AbstractPepeunitRestClient] = None,
35
+ cycle_speed: float = 0.1,
36
+ restart_mode: RestartMode = RestartMode.RESTART_EXEC,
37
+ skip_version_check=False
38
+ ):
39
+ self.env_file_path = env_file_path
40
+ self.schema_file_path = schema_file_path
41
+ self.log_file_path = log_file_path
42
+ self.enable_mqtt = enable_mqtt
43
+ self.enable_rest = enable_rest
44
+ self.cycle_speed = cycle_speed
45
+ self.restart_mode = restart_mode
46
+ self.skip_version_check = skip_version_check
47
+
48
+ self.settings = Settings(env_file_path)
49
+ self.schema = SchemaManager(schema_file_path)
50
+ self.logger = Logger(log_file_path, None, self.schema, self.settings)
51
+
52
+ self.mqtt_client = (mqtt_client if mqtt_client else self._get_default_mqtt_client()) if enable_mqtt else None
53
+ self.rest_client = (rest_client if rest_client else self._get_default_rest_client()) if enable_rest else None
54
+
55
+ if self.mqtt_client:
56
+ self.logger.mqtt_client = self.mqtt_client
57
+
58
+ self.mqtt_input_handler: Optional[Callable] = None
59
+ self.mqtt_output_handler: Optional[Callable] = None
60
+ self.custom_update_handler: Optional[Callable] = None
61
+
62
+ self._running = False
63
+ self._last_state_send = 0
64
+
65
+ def _get_default_mqtt_client(self) -> Optional[AbstractPepeunitMqttClient]:
66
+ return PepeunitMqttClient(self.settings, self.schema, self.logger)
67
+
68
+ def _get_default_rest_client(self) -> Optional[AbstractPepeunitRestClient]:
69
+ return PepeunitRestClient(self.settings)
70
+
71
+ def get_system_state(self) -> Dict[str, Any]:
72
+ if psutil is not None:
73
+ try:
74
+ memory_info = psutil.virtual_memory()
75
+
76
+ try:
77
+ cpu_freq = psutil.cpu_freq()
78
+ freq = cpu_freq.current if cpu_freq else 0
79
+ except (AttributeError, OSError):
80
+ freq = 0
81
+
82
+ return {
83
+ 'millis': round(time.time() * 1000),
84
+ 'mem_free': memory_info.available,
85
+ 'mem_alloc': memory_info.total - memory_info.available,
86
+ 'freq': freq,
87
+ 'commit_version': self.settings.COMMIT_VERSION,
88
+ }
89
+ except Exception:
90
+ pass
91
+
92
+ return {
93
+ 'millis': round(time.time() * 1000),
94
+ 'mem_free': 0,
95
+ 'mem_alloc': 0,
96
+ 'freq': 0,
97
+ 'commit_version': self.settings.COMMIT_VERSION,
98
+ }
99
+
100
+ def set_mqtt_input_handler(self, handler: Callable) -> None:
101
+
102
+ if not self.mqtt_client:
103
+ raise RuntimeError("MQTT client is not available")
104
+
105
+ self.mqtt_input_handler = handler
106
+
107
+ def combined_handler(msg):
108
+ self._base_mqtt_input_func(msg)
109
+ if self.mqtt_input_handler:
110
+ self.mqtt_input_handler(self, msg)
111
+
112
+ self.mqtt_client.set_input_handler(combined_handler)
113
+
114
+ def _base_mqtt_input_func(self, msg) -> None:
115
+ try:
116
+ for topic_key in self.schema.input_base_topic:
117
+ if msg.topic in self.schema.input_base_topic[topic_key]:
118
+ self.logger.info(f'Get base MQTT command: {topic_key}')
119
+
120
+ if topic_key == BaseInputTopicType.ENV_UPDATE_PEPEUNIT.value:
121
+ self.download_env(self.env_file_path)
122
+ elif topic_key == BaseInputTopicType.SCHEMA_UPDATE_PEPEUNIT.value:
123
+ self.download_schema(self.schema_file_path)
124
+ elif topic_key == BaseInputTopicType.UPDATE_PEPEUNIT.value:
125
+ self._handle_update(msg)
126
+ elif topic_key == BaseInputTopicType.LOG_SYNC_PEPEUNIT.value:
127
+ self._handle_log_sync()
128
+ break
129
+ except Exception as e:
130
+ self.logger.error(f"Error in base MQTT input handler: {str(e)}")
131
+
132
+ def download_env(self, file_path: str) -> None:
133
+ if not self.rest_client:
134
+ raise RuntimeError("REST client is not available")
135
+
136
+ if not self.mqtt_client:
137
+ raise RuntimeError("MQTT client is not available")
138
+
139
+ self.rest_client.download_env(file_path)
140
+ self.settings.load_from_file()
141
+ self.logger.info('Success update env')
142
+
143
+ def download_schema(self, file_path: str) -> None:
144
+ if not self.rest_client:
145
+ raise RuntimeError("REST client is not available")
146
+
147
+ if not self.mqtt_client:
148
+ raise RuntimeError("MQTT client is not available")
149
+
150
+ self.rest_client.download_schema(file_path)
151
+ self.schema.update_from_file()
152
+ self.subscribe_all_schema_topics()
153
+ self.logger.info('Success update schema')
154
+
155
+ def set_state_storage(self, state: str) -> None:
156
+ if not self.rest_client:
157
+ raise RuntimeError("REST client is not available")
158
+
159
+ self.rest_client.set_state_storage(state)
160
+
161
+ def get_state_storage(self) -> str:
162
+ if not self.rest_client:
163
+ raise RuntimeError("REST client is not available")
164
+
165
+ return self.rest_client.get_state_storage()
166
+
167
+ def _handle_update(self, msg) -> None:
168
+
169
+ payload = json.loads(msg.payload) if msg.payload else {}
170
+
171
+ if not self.rest_client:
172
+ raise RuntimeError("REST client is not available")
173
+
174
+ if not self.mqtt_client:
175
+ raise RuntimeError("MQTT client is not available")
176
+
177
+ if not self.skip_version_check and self.settings.COMMIT_VERSION == payload.get('NEW_COMMIT_VERSION'):
178
+ self.logger.info('No update needed: current version = target version')
179
+ return
180
+
181
+ if self.custom_update_handler:
182
+ self.custom_update_handler(self, payload)
183
+ else:
184
+ temp_dir = tempfile.gettempdir()
185
+ archive_path = os.path.join(temp_dir, f"update_{self.settings.unit_uuid}.tar.gz")
186
+
187
+ self.rest_client.download_update(archive_path)
188
+ self.logger.info('Success download update archive', file_only=True)
189
+
190
+ self.update_device_program(archive_path)
191
+ self.logger.info('Success extract archive', file_only=True)
192
+
193
+ self.logger.info("Full update cycle completed successfully")
194
+
195
+ def update_device_program(self, archive_path: str) -> None:
196
+
197
+ unit_directory = os.path.dirname(self.env_file_path) or os.getcwd()
198
+ with tempfile.TemporaryDirectory() as temp_extract_dir:
199
+ FileManager.extract_tar_gz(archive_path, temp_extract_dir)
200
+ self.logger.info(f"Extracted archive to {temp_extract_dir}")
201
+
202
+ FileManager.copy_directory_contents(temp_extract_dir, unit_directory)
203
+ self.logger.info(f"Copied directory contents from {temp_extract_dir} to {unit_directory}")
204
+
205
+ os.remove(archive_path)
206
+ self.logger.info(f"Archive removed {archive_path}")
207
+
208
+ if self.restart_mode == RestartMode.RESTART_POPEN:
209
+ self.stop_main_cycle()
210
+
211
+ self.logger.info('Run new main cycle in other process')
212
+ subprocess.Popen([sys.executable] + sys.argv)
213
+
214
+ self.logger.info('I`ll Be Back - stop this process')
215
+ sys.exit(0)
216
+ elif self.restart_mode == RestartMode.RESTART_EXEC:
217
+ self.stop_main_cycle()
218
+
219
+ self.logger.info('I`ll Be Back - replacing current process')
220
+ os.execv(sys.executable, [sys.executable] + sys.argv)
221
+ elif self.restart_mode == RestartMode.ENV_SCHEMA_ONLY:
222
+ self.logger.info('Updating env and schema only, without restart')
223
+ self._update_env_schema_only()
224
+ elif self.restart_mode == RestartMode.NO_RESTART:
225
+ self.logger.info('Archive extracted, no restart or updates performed')
226
+
227
+ def _update_env_schema_only(self) -> None:
228
+ self.settings.load_from_file()
229
+ self.schema.update_from_file()
230
+
231
+ if self.enable_mqtt and self.mqtt_client:
232
+ self.subscribe_all_schema_topics()
233
+
234
+ self.logger.info('Environment and schema updated successfully')
235
+
236
+ def _handle_log_sync(self) -> None:
237
+ topic = self.schema.output_base_topic[BaseOutputTopicType.LOG_PEPEUNIT.value][0]
238
+ log_data = self.logger.get_full_log()
239
+ if self.mqtt_client:
240
+ self.mqtt_client.publish(topic, json.dumps(log_data))
241
+ self.logger.info("Log sync completed")
242
+
243
+ def subscribe_all_schema_topics(self) -> None:
244
+ if not self.mqtt_client:
245
+ raise RuntimeError("MQTT client is not available")
246
+
247
+ topics = []
248
+
249
+ for topic_list in self.schema.input_base_topic.values():
250
+ topics.extend(topic_list)
251
+
252
+ for topic_list in self.schema.input_topic.values():
253
+ topics.extend(topic_list)
254
+
255
+ self.logger.info(f'Need a subscription for {len(topic_list)} topics')
256
+
257
+ self.mqtt_client.subscribe_topics(topics)
258
+
259
+ def publish_to_topics(self, topic_key: str, message: str) -> None:
260
+ if not self.mqtt_client:
261
+ raise RuntimeError("MQTT client is not available")
262
+
263
+ topics = []
264
+
265
+ if topic_key in self.schema.output_topic:
266
+ topics.extend(self.schema.output_topic[topic_key])
267
+ elif topic_key in self.schema.output_base_topic:
268
+ topics.extend(self.schema.output_base_topic[topic_key])
269
+
270
+ for topic in topics:
271
+ self.mqtt_client.publish(topic, message)
272
+
273
+ def _base_mqtt_output_handler(self) -> None:
274
+ current_time = time.time()
275
+ if BaseOutputTopicType.STATE_PEPEUNIT.value in self.schema.output_base_topic:
276
+ if current_time - self._last_state_send >= self.settings.STATE_SEND_INTERVAL:
277
+ topic = self.schema.output_base_topic[BaseOutputTopicType.STATE_PEPEUNIT.value][0]
278
+ state_data = self.get_system_state()
279
+ if self.mqtt_client:
280
+ self.mqtt_client.publish(topic, json.dumps(state_data))
281
+
282
+ self._last_state_send = current_time
283
+
284
+ def run_main_cycle(self) -> None:
285
+ self._running = True
286
+ try:
287
+ while self._running:
288
+ self._base_mqtt_output_handler()
289
+
290
+ if self.mqtt_output_handler:
291
+ self.mqtt_output_handler(self)
292
+
293
+ time.sleep(self.cycle_speed)
294
+
295
+ except Exception as e:
296
+ self.logger.error(f"Error in main cycle: {str(e)}")
297
+ finally:
298
+ self._running = False
299
+
300
+ def set_output_handler(self, output_handler: Callable) -> None:
301
+ self.mqtt_output_handler = output_handler
302
+
303
+ def set_custom_update_handler(self, custom_update_handler: Callable) -> None:
304
+ self.custom_update_handler = custom_update_handler
305
+
306
+ def stop_main_cycle(self) -> None:
307
+ self.logger.info(f'Main cycle stopped')
308
+ self._running = False
@@ -0,0 +1,56 @@
1
+ from enum import Enum
2
+
3
+
4
+ class LogLevel(Enum):
5
+ DEBUG = 'Debug'
6
+ INFO = 'Info'
7
+ WARNING = 'Warning'
8
+ ERROR = 'Error'
9
+ CRITICAL = 'Critical'
10
+
11
+ def get_int_level(self) -> int:
12
+ level_mapping = {
13
+ LogLevel.DEBUG: 0,
14
+ LogLevel.INFO: 1,
15
+ LogLevel.WARNING: 2,
16
+ LogLevel.ERROR: 3,
17
+ LogLevel.CRITICAL: 4,
18
+ }
19
+ return level_mapping[self]
20
+
21
+
22
+ class SearchTopicType(Enum):
23
+ UNIT_NODE_UUID = 'unit_node_uuid'
24
+ FULL_NAME = 'full_name'
25
+
26
+
27
+ class SearchScope(Enum):
28
+ ALL = 'all'
29
+ INPUT = 'input'
30
+ OUTPUT = 'output'
31
+
32
+
33
+ class DestinationTopicType(Enum):
34
+ INPUT_BASE_TOPIC = 'input_base_topic'
35
+ OUTPUT_BASE_TOPIC = 'output_base_topic'
36
+ INPUT_TOPIC = 'input_topic'
37
+ OUTPUT_TOPIC = 'output_topic'
38
+
39
+
40
+ class BaseInputTopicType(Enum):
41
+ UPDATE_PEPEUNIT = 'update/pepeunit'
42
+ ENV_UPDATE_PEPEUNIT = 'env_update/pepeunit'
43
+ SCHEMA_UPDATE_PEPEUNIT = 'schema_update/pepeunit'
44
+ LOG_SYNC_PEPEUNIT = 'log_sync/pepeunit'
45
+
46
+
47
+ class BaseOutputTopicType(Enum):
48
+ LOG_PEPEUNIT = 'log/pepeunit'
49
+ STATE_PEPEUNIT = 'state/pepeunit'
50
+
51
+
52
+ class RestartMode(Enum):
53
+ RESTART_POPEN = 'restart_popen'
54
+ RESTART_EXEC = 'restart_exec'
55
+ ENV_SCHEMA_ONLY = 'env_schema_only'
56
+ NO_RESTART = 'no_restart'
@@ -0,0 +1,159 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tarfile
5
+ import zlib
6
+ from typing import Any, Dict, List
7
+ from pathlib import Path
8
+
9
+
10
+ class FileManager:
11
+ @staticmethod
12
+ def read_json(file_path: str) -> Dict[str, Any]:
13
+ with open(file_path, 'r', encoding='utf-8') as f:
14
+ return json.load(f)
15
+
16
+ @staticmethod
17
+ def write_json(file_path: str, data: Dict[str, Any], indent: int = 4) -> None:
18
+ directory = os.path.dirname(file_path)
19
+
20
+ if directory:
21
+ os.makedirs(directory, exist_ok=True)
22
+ with open(file_path, 'w', encoding='utf-8') as f:
23
+ json.dump(data, f, indent=indent, ensure_ascii=False)
24
+
25
+ @staticmethod
26
+ def copy_file(source_path: str, destination_path: str) -> None:
27
+ shutil.copy2(source_path, destination_path)
28
+
29
+ @staticmethod
30
+ def file_exists(file_path: str) -> bool:
31
+ return os.path.exists(file_path)
32
+
33
+ @staticmethod
34
+ def create_directory(directory_path: str) -> None:
35
+ os.makedirs(directory_path, exist_ok=True)
36
+
37
+ @staticmethod
38
+ def extract_tar_gz(archive_path: str, extract_path: str) -> None:
39
+ FileManager.extract_pepeunit_archive(archive_path, extract_path)
40
+
41
+ @staticmethod
42
+ def extract_pepeunit_archive(file_path: str, extract_path: str) -> None:
43
+ with open(file_path, 'rb') as f:
44
+ producer = zlib.decompressobj(wbits=9)
45
+ tar_data = producer.decompress(f.read()) + producer.flush()
46
+ tar_filepath = f'{os.path.dirname(file_path)}/temp_update.tar'
47
+ with open(tar_filepath, 'wb') as tar_file:
48
+ tar_file.write(tar_data)
49
+ try:
50
+ # Use tarfile directly with filter for security (Python 3.12+)
51
+ with tarfile.open(tar_filepath, 'r') as tar:
52
+ # Check if filter parameter is supported
53
+ try:
54
+ tar.extractall(extract_path, filter='data')
55
+ except TypeError:
56
+ # Fallback for older Python versions
57
+ tar.extractall(extract_path)
58
+ finally:
59
+ if os.path.exists(tar_filepath):
60
+ os.remove(tar_filepath)
61
+
62
+ @staticmethod
63
+ def copy_directory_contents(source_path: str, destination_path: str) -> None:
64
+ if not os.path.exists(source_path):
65
+ raise FileNotFoundError(f"Source directory does not exist: {source_path}")
66
+
67
+ os.makedirs(destination_path, exist_ok=True)
68
+
69
+ for item in os.listdir(source_path):
70
+ source_item = os.path.join(source_path, item)
71
+ destination_item = os.path.join(destination_path, item)
72
+
73
+ if os.path.isdir(source_item):
74
+ shutil.copytree(source_item, destination_item, dirs_exist_ok=True)
75
+ else:
76
+ shutil.copy2(source_item, destination_item)
77
+
78
+ @staticmethod
79
+ def remove_directory(directory_path: str) -> None:
80
+ if os.path.exists(directory_path):
81
+ shutil.rmtree(directory_path)
82
+
83
+ @staticmethod
84
+ def append_ndjson_with_limit(file_path: str, item: Dict[str, Any], max_lines: int = None) -> None:
85
+ directory = os.path.dirname(file_path)
86
+ if directory:
87
+ os.makedirs(directory, exist_ok=True)
88
+
89
+ if os.path.exists(file_path):
90
+ with open(file_path, 'r', encoding='utf-8') as f:
91
+ first_char = f.read(1)
92
+ if first_char == '[':
93
+ try:
94
+ f.seek(0)
95
+ data = json.load(f)
96
+ if isinstance(data, list):
97
+ with open(file_path, 'w', encoding='utf-8') as fw:
98
+ for it in data:
99
+ json.dump(it, fw, ensure_ascii=False)
100
+ fw.write('\n')
101
+ except Exception:
102
+ pass
103
+
104
+ try:
105
+ with open(file_path, 'a', encoding='utf-8') as f:
106
+ json.dump(item, f, ensure_ascii=False)
107
+ f.write('\n')
108
+ except Exception:
109
+ pass
110
+
111
+ if max_lines is not None and max_lines > 0:
112
+ FileManager.trim_ndjson(file_path, max_lines)
113
+
114
+ @staticmethod
115
+ def iter_ndjson(file_path: str):
116
+ try:
117
+ with open(file_path, 'r', encoding='utf-8') as f:
118
+ for line in f:
119
+ line = line.strip()
120
+ if not line:
121
+ continue
122
+ try:
123
+ yield json.loads(line)
124
+ except Exception:
125
+ continue
126
+ except Exception:
127
+ return
128
+
129
+ @staticmethod
130
+ def trim_ndjson(file_path: str, max_lines: int) -> None:
131
+ if max_lines <= 0:
132
+ return
133
+
134
+ try:
135
+ total = 0
136
+ with open(file_path, 'r', encoding='utf-8') as f:
137
+ for _ in f:
138
+ total += 1
139
+
140
+ if total <= max_lines:
141
+ return
142
+
143
+ to_skip = total - max_lines
144
+ tmp_path = file_path + '.tmp'
145
+
146
+ with open(file_path, 'r', encoding='utf-8') as src, open(tmp_path, 'w', encoding='utf-8') as dst:
147
+ for line in src:
148
+ if to_skip > 0:
149
+ to_skip -= 1
150
+ continue
151
+ dst.write(line)
152
+
153
+ os.replace(tmp_path, file_path)
154
+ except Exception:
155
+ if os.path.exists(tmp_path):
156
+ try:
157
+ os.remove(tmp_path)
158
+ except Exception:
159
+ pass