router-cli 0.2.0__py3-none-any.whl → 0.3.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.
router_cli/models.py ADDED
@@ -0,0 +1,165 @@
1
+ """Data models for router CLI."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ class RouterError(Exception):
7
+ """Base exception for router errors."""
8
+
9
+ pass
10
+
11
+
12
+ class AuthenticationError(RouterError):
13
+ """Raised when authentication fails or session expires."""
14
+
15
+ pass
16
+
17
+
18
+ class ConnectionError(RouterError):
19
+ """Raised when unable to connect to the router."""
20
+
21
+ pass
22
+
23
+
24
+ class HTTPError(RouterError):
25
+ """Raised for HTTP error responses."""
26
+
27
+ def __init__(self, message: str, status_code: int | None = None):
28
+ super().__init__(message)
29
+ self.status_code = status_code
30
+
31
+
32
+ @dataclass
33
+ class WANConnection:
34
+ """A WAN connection entry."""
35
+
36
+ interface: str = ""
37
+ description: str = ""
38
+ status: str = ""
39
+ ipv4: str = ""
40
+
41
+ def to_dict(self) -> dict:
42
+ """Convert to dictionary for backward compatibility."""
43
+ return {
44
+ "interface": self.interface,
45
+ "description": self.description,
46
+ "status": self.status,
47
+ "ipv4": self.ipv4,
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class RouterStatus:
53
+ """Parsed router status information."""
54
+
55
+ # System Info
56
+ model_name: str = ""
57
+ time_date: str = ""
58
+ firmware: str = ""
59
+
60
+ # Internet Info
61
+ default_gateway: str = ""
62
+ preferred_dns: str = ""
63
+ alternate_dns: str = ""
64
+
65
+ # WAN Connections
66
+ wan_connections: list[WANConnection] = field(default_factory=list)
67
+
68
+ # Wireless Info
69
+ ssid: str = ""
70
+ wireless_mac: str = ""
71
+ wireless_status: str = ""
72
+ security_mode: str = ""
73
+
74
+ # Local Network
75
+ local_mac: str = ""
76
+ local_ip: str = ""
77
+ subnet_mask: str = ""
78
+ dhcp_server: str = ""
79
+
80
+
81
+ @dataclass
82
+ class WirelessClient:
83
+ """A connected wireless client."""
84
+
85
+ mac: str
86
+ associated: bool
87
+ authorized: bool
88
+ ssid: str
89
+ interface: str
90
+
91
+
92
+ @dataclass
93
+ class DHCPLease:
94
+ """A DHCP lease entry."""
95
+
96
+ hostname: str
97
+ mac: str
98
+ ip: str
99
+ expires_in: str
100
+
101
+
102
+ @dataclass
103
+ class Route:
104
+ """A routing table entry."""
105
+
106
+ destination: str
107
+ gateway: str
108
+ subnet_mask: str
109
+ flag: str
110
+ metric: int
111
+ service: str
112
+
113
+
114
+ @dataclass
115
+ class InterfaceStats:
116
+ """Statistics for a network interface."""
117
+
118
+ interface: str
119
+ rx_bytes: int
120
+ rx_packets: int
121
+ rx_errors: int
122
+ rx_drops: int
123
+ tx_bytes: int
124
+ tx_packets: int
125
+ tx_errors: int
126
+ tx_drops: int
127
+
128
+
129
+ @dataclass
130
+ class ADSLStats:
131
+ """ADSL line statistics."""
132
+
133
+ mode: str = ""
134
+ traffic_type: str = ""
135
+ status: str = ""
136
+ link_power_state: str = ""
137
+ downstream_rate: int = 0
138
+ upstream_rate: int = 0
139
+ downstream_snr_margin: float = 0.0
140
+ upstream_snr_margin: float = 0.0
141
+ downstream_attenuation: float = 0.0
142
+ upstream_attenuation: float = 0.0
143
+ downstream_output_power: float = 0.0
144
+ upstream_output_power: float = 0.0
145
+ downstream_attainable_rate: int = 0
146
+ upstream_attainable_rate: int = 0
147
+
148
+
149
+ @dataclass
150
+ class Statistics:
151
+ """Network and ADSL statistics."""
152
+
153
+ lan_interfaces: list[InterfaceStats] = field(default_factory=list)
154
+ wan_interfaces: list[InterfaceStats] = field(default_factory=list)
155
+ adsl: ADSLStats = field(default_factory=ADSLStats)
156
+
157
+
158
+ @dataclass
159
+ class LogEntry:
160
+ """A system log entry."""
161
+
162
+ datetime: str
163
+ facility: str
164
+ severity: str
165
+ message: str
router_cli/parser.py ADDED
@@ -0,0 +1,521 @@
1
+ """HTML parsing utilities for router responses.
2
+
3
+ The router's HTML is often malformed (unclosed tags, mixed case, etc.).
4
+ These parsers use flexible regex patterns with re.IGNORECASE and re.DOTALL
5
+ to handle the inconsistencies.
6
+ """
7
+
8
+ import re
9
+
10
+ from .models import (
11
+ ADSLStats,
12
+ DHCPLease,
13
+ InterfaceStats,
14
+ LogEntry,
15
+ Route,
16
+ RouterStatus,
17
+ Statistics,
18
+ WANConnection,
19
+ WirelessClient,
20
+ )
21
+
22
+
23
+ def extract_value(html: str, *patterns: str) -> str:
24
+ """Try multiple regex patterns and return first match.
25
+
26
+ Args:
27
+ html: The HTML content to search
28
+ *patterns: One or more regex patterns to try in order
29
+
30
+ Returns:
31
+ The first captured group from the first matching pattern,
32
+ or "N/A" if no pattern matches.
33
+ """
34
+ for pattern in patterns:
35
+ match = re.search(pattern, html, re.DOTALL | re.IGNORECASE)
36
+ if match:
37
+ value = match.group(1).strip()
38
+ # Clean up HTML entities
39
+ value = value.replace(" ", "").strip()
40
+ if value:
41
+ return value
42
+ return "N/A"
43
+
44
+
45
+ def parse_status(html: str) -> RouterStatus:
46
+ """Parse status information from HTML.
47
+
48
+ Parses the /info.html page to extract router status including
49
+ system info, internet info, WAN connections, wireless info,
50
+ and local network settings.
51
+ """
52
+ status = RouterStatus()
53
+
54
+ # System Info - from JavaScript variables or table cells
55
+ status.model_name = extract_value(
56
+ html,
57
+ r"var\s+modeName\s*=\s*[\"']([^\"']+)[\"']",
58
+ r"Model Name:.*?<td[^>]*>([^<]+)</td>",
59
+ )
60
+
61
+ status.time_date = extract_value(
62
+ html,
63
+ # From document.writeln() in JS
64
+ r"Time and Date:.*?<td>([^<]+)</td>",
65
+ # Static HTML fallback
66
+ r"Time and Date:.*?<td[^>]*>([^<]+)</td>",
67
+ )
68
+
69
+ status.firmware = extract_value(
70
+ html,
71
+ r"Firmware Version:\s*([A-Z0-9_.]+)",
72
+ r"<td[^>]*>Firmware Version:</td>\s*<td[^>]*>([^<]+)</td>",
73
+ )
74
+
75
+ # Internet Info - parse from JS variables first, then static HTML
76
+ status.default_gateway = extract_value(
77
+ html,
78
+ r"var\s+dfltGw\s*=\s*[\"']([^\"']+)[\"']",
79
+ r"Default Gateway:.*?<td[^>]*>([^<]+)</td>",
80
+ )
81
+
82
+ status.preferred_dns = extract_value(
83
+ html, r"Preferred DNS Server:.*?<td[^>]*>([^<]+)</td>"
84
+ )
85
+
86
+ status.alternate_dns = extract_value(
87
+ html, r"Alternate DNS Server:.*?<td[^>]*>([^<]+)</td>"
88
+ )
89
+
90
+ # WAN Connections - parse table rows with class="hd"
91
+ status.wan_connections = parse_wan_connections(html)
92
+
93
+ # Wireless Info - find section and extract all values
94
+ wireless_section = re.search(
95
+ r"Wireless Info:.*?Local Network Info", html, re.DOTALL
96
+ )
97
+ if wireless_section:
98
+ ws = wireless_section.group(0)
99
+ status.ssid = extract_value(
100
+ ws,
101
+ r"<option[^>]*selected[^>]*>\s*([^<\n]+?)\s*</option>",
102
+ )
103
+ status.wireless_mac = extract_value(ws, r"MAC Address:.*?<td[^>]*>([^<]+)</td>")
104
+ status.wireless_status = extract_value(ws, r"Status:.*?<td[^>]*>([^<]+)</td>")
105
+ status.security_mode = extract_value(
106
+ ws, r"Security Mode:.*?<td[^>]*>([^<]+)</td>"
107
+ )
108
+
109
+ # Local Network Info - find section after "Local Network Info"
110
+ # Note: MAC address may have malformed HTML (</SPAN> without opening tag)
111
+ local_section = re.search(
112
+ r"Local Network Info.*?(?:Storage Device|$)", html, re.DOTALL
113
+ )
114
+ if local_section:
115
+ ls = local_section.group(0)
116
+ status.local_mac = extract_value(
117
+ ls,
118
+ r"MAC Address:</TD>\s*<TD>([^<]+)",
119
+ r"MAC Address:.*?<td[^>]*>([^<]+)</td>",
120
+ )
121
+ status.local_ip = extract_value(ls, r"IP Address:.*?<td[^>]*>([^<]+)</td>")
122
+ status.subnet_mask = extract_value(ls, r"Subnet Mask:.*?<td[^>]*>([^<]+)</td>")
123
+ # DHCP may be in document.writeln() or static HTML
124
+ status.dhcp_server = extract_value(
125
+ ls,
126
+ r"DHCP Server:.*?<td>([^<]+)</td>",
127
+ r"DHCP Server:.*?<td[^>]*>([^<]+)</td>",
128
+ )
129
+
130
+ return status
131
+
132
+
133
+ def parse_wan_connections(html: str) -> list[WANConnection]:
134
+ """Parse WAN connection table from HTML.
135
+
136
+ Looks for the "Enabled WAN Connections:" table and extracts
137
+ interface, description, status, and IPv4 address from each row.
138
+ """
139
+ connections = []
140
+
141
+ # Find the WAN connections table section
142
+ wan_section = re.search(
143
+ r"Enabled WAN Connections:.*?</table>", html, re.DOTALL | re.IGNORECASE
144
+ )
145
+ if not wan_section:
146
+ return connections
147
+
148
+ section = wan_section.group(0)
149
+
150
+ # Find data rows - handle both single and double quotes
151
+ # Pattern matches: <tr align='center'> or <tr align="center">
152
+ rows = re.findall(
153
+ r"<tr[^>]*align=[\"']center[\"'][^>]*>(.*?)</tr>",
154
+ section,
155
+ re.DOTALL | re.IGNORECASE,
156
+ )
157
+
158
+ for row in rows:
159
+ # Match cells with class='hd' or class="hd"
160
+ cells = re.findall(
161
+ r"<td[^>]*class=[\"']hd[\"'][^>]*>([^<]*)</td>",
162
+ row,
163
+ re.IGNORECASE,
164
+ )
165
+ if len(cells) >= 4:
166
+ connections.append(
167
+ WANConnection(
168
+ interface=cells[0].strip(),
169
+ description=cells[1].strip(),
170
+ status=cells[2].strip(),
171
+ ipv4=cells[3].strip(),
172
+ )
173
+ )
174
+
175
+ return connections
176
+
177
+
178
+ def parse_wireless_clients(html: str) -> list[WirelessClient]:
179
+ """Parse wireless clients from HTML.
180
+
181
+ Parses the /wlstationlist.cmd page to extract connected
182
+ wireless clients with their MAC, association/authorization status,
183
+ SSID, and interface.
184
+ """
185
+ clients = []
186
+
187
+ # Find all data rows in the table
188
+ rows = re.findall(
189
+ r"<tr>\s*<td><p align=center>\s*([A-Fa-f0-9:]+)\s*"
190
+ r".*?<p align=center>\s*(Yes|No)\s*</p>.*?"
191
+ r"<p align=center>\s*(Yes|No)\s*</p>.*?"
192
+ r"<p align=center>\s*([^<&]+?)(?:&nbsp)?\s*</td>.*?"
193
+ r"<p align=center>\s*([^<&]+?)(?:&nbsp)?\s*</td>",
194
+ html,
195
+ re.DOTALL | re.IGNORECASE,
196
+ )
197
+
198
+ for row in rows:
199
+ mac, associated, authorized, ssid, interface = row
200
+ clients.append(
201
+ WirelessClient(
202
+ mac=mac.strip(),
203
+ associated=associated.lower() == "yes",
204
+ authorized=authorized.lower() == "yes",
205
+ ssid=ssid.strip(),
206
+ interface=interface.strip(),
207
+ )
208
+ )
209
+
210
+ return clients
211
+
212
+
213
+ def parse_dhcp_leases(html: str) -> list[DHCPLease]:
214
+ """Parse DHCP leases from HTML.
215
+
216
+ Parses the /dhcpinfo.html page to extract active DHCP leases
217
+ with hostname, MAC address, IP address, and expiry time.
218
+ """
219
+ leases = []
220
+
221
+ # Find the DHCP table section
222
+ table_match = re.search(
223
+ r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
224
+ )
225
+ if not table_match:
226
+ return leases
227
+
228
+ table = table_match.group(0)
229
+
230
+ # Find data rows (skip header row)
231
+ rows = re.findall(
232
+ r"<tr><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td></tr>",
233
+ table,
234
+ re.IGNORECASE,
235
+ )
236
+
237
+ for row in rows:
238
+ hostname, mac, ip, expires = row
239
+ leases.append(
240
+ DHCPLease(
241
+ hostname=hostname.strip(),
242
+ mac=mac.strip(),
243
+ ip=ip.strip(),
244
+ expires_in=expires.strip(),
245
+ )
246
+ )
247
+
248
+ return leases
249
+
250
+
251
+ def parse_routes(html: str) -> list[Route]:
252
+ """Parse routing table from HTML.
253
+
254
+ Parses the /rtroutecfg.cmd page to extract routing table entries
255
+ with destination, gateway, subnet mask, flags, metric, and service.
256
+ """
257
+ routes = []
258
+
259
+ # Find the routing table
260
+ table_match = re.search(
261
+ r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
262
+ )
263
+ if not table_match:
264
+ return routes
265
+
266
+ table = table_match.group(0)
267
+
268
+ # Find data rows - 6 cells per row
269
+ rows = re.findall(
270
+ r"<tr>\s*"
271
+ r"<td>([^<]*)</td>\s*"
272
+ r"<td>([^<]*)</td>\s*"
273
+ r"<td>([^<]*)</td>\s*"
274
+ r"<td>([^<]*)</td>\s*"
275
+ r"<td>([^<]*)</td>\s*"
276
+ r"<td>([^<]*)</td>\s*"
277
+ r"</tr>",
278
+ table,
279
+ re.IGNORECASE,
280
+ )
281
+
282
+ for row in rows:
283
+ dest, gw, mask, flag, metric, service = row
284
+ # Skip header row
285
+ if "Destination" in dest:
286
+ continue
287
+ routes.append(
288
+ Route(
289
+ destination=dest.strip(),
290
+ gateway=gw.strip(),
291
+ subnet_mask=mask.strip(),
292
+ flag=flag.strip(),
293
+ metric=int(metric.strip()) if metric.strip().isdigit() else 0,
294
+ service=service.replace("&nbsp;", "").strip(),
295
+ )
296
+ )
297
+
298
+ return routes
299
+
300
+
301
+ def parse_statistics(html: str) -> Statistics:
302
+ """Parse network statistics from HTML.
303
+
304
+ Parses the /statsifcwanber.html page to extract LAN interface stats,
305
+ WAN interface stats, and ADSL line statistics.
306
+ """
307
+ stats = Statistics()
308
+
309
+ # Parse LAN interface stats - look for rows with 9 cells
310
+ lan_section = re.search(
311
+ r"Local Network.*?</table>", html, re.DOTALL | re.IGNORECASE
312
+ )
313
+ if lan_section:
314
+ stats.lan_interfaces = parse_interface_stats(lan_section.group(0))
315
+
316
+ # Parse WAN interface stats
317
+ wan_section = re.search(
318
+ r"<td class=topheader>\s*Internet\s*</td>.*?</table>",
319
+ html,
320
+ re.DOTALL | re.IGNORECASE,
321
+ )
322
+ if wan_section:
323
+ stats.wan_interfaces = parse_wan_interface_stats(wan_section.group(0))
324
+
325
+ # Parse ADSL stats
326
+ stats.adsl = parse_adsl_stats(html)
327
+
328
+ return stats
329
+
330
+
331
+ def parse_interface_stats(html: str) -> list[InterfaceStats]:
332
+ """Parse LAN interface statistics table."""
333
+ interfaces = []
334
+
335
+ # Find rows with 9 numeric values
336
+ rows = re.findall(
337
+ r"<tr>\s*<td class='hd'>.*?</script>\s*</td>\s*"
338
+ r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*"
339
+ r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*</tr>",
340
+ html,
341
+ re.DOTALL | re.IGNORECASE,
342
+ )
343
+
344
+ # Extract interface names from script blocks
345
+ intf_names = re.findall(r"brdIntf\s*=\s*['\"]([^'\"]+)['\"]", html, re.IGNORECASE)
346
+
347
+ for i, row in enumerate(rows):
348
+ intf_name = intf_names[i].split("|")[-1] if i < len(intf_names) else f"eth{i}"
349
+ (
350
+ rx_bytes,
351
+ rx_pkts,
352
+ rx_errs,
353
+ rx_drops,
354
+ tx_bytes,
355
+ tx_pkts,
356
+ tx_errs,
357
+ tx_drops,
358
+ ) = row
359
+ interfaces.append(
360
+ InterfaceStats(
361
+ interface=intf_name,
362
+ rx_bytes=int(rx_bytes),
363
+ rx_packets=int(rx_pkts),
364
+ rx_errors=int(rx_errs),
365
+ rx_drops=int(rx_drops),
366
+ tx_bytes=int(tx_bytes),
367
+ tx_packets=int(tx_pkts),
368
+ tx_errors=int(tx_errs),
369
+ tx_drops=int(tx_drops),
370
+ )
371
+ )
372
+
373
+ return interfaces
374
+
375
+
376
+ def parse_wan_interface_stats(html: str) -> list[InterfaceStats]:
377
+ """Parse WAN interface statistics table."""
378
+ interfaces = []
379
+
380
+ # Find rows with interface name, description, and 8 numeric values
381
+ rows = re.findall(
382
+ r"<tr>\s*<td class='hd'>([^<]+)</td>\s*"
383
+ r"<td>([^<]+)</td>\s*"
384
+ r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*"
385
+ r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*</tr>",
386
+ html,
387
+ re.DOTALL | re.IGNORECASE,
388
+ )
389
+
390
+ for row in rows:
391
+ (
392
+ intf_name,
393
+ _desc,
394
+ rx_bytes,
395
+ rx_pkts,
396
+ rx_errs,
397
+ rx_drops,
398
+ tx_bytes,
399
+ tx_pkts,
400
+ tx_errs,
401
+ tx_drops,
402
+ ) = row
403
+ interfaces.append(
404
+ InterfaceStats(
405
+ interface=intf_name.strip(),
406
+ rx_bytes=int(rx_bytes),
407
+ rx_packets=int(rx_pkts),
408
+ rx_errors=int(rx_errs),
409
+ rx_drops=int(rx_drops),
410
+ tx_bytes=int(tx_bytes),
411
+ tx_packets=int(tx_pkts),
412
+ tx_errors=int(tx_errs),
413
+ tx_drops=int(tx_drops),
414
+ )
415
+ )
416
+
417
+ return interfaces
418
+
419
+
420
+ def parse_adsl_stats(html: str) -> ADSLStats:
421
+ """Parse ADSL statistics from HTML.
422
+
423
+ Extracts mode, status, link power state, rates, SNR margins,
424
+ attenuation, and output power levels.
425
+ """
426
+ adsl = ADSLStats()
427
+
428
+ adsl.mode = extract_value(html, r"Mode:</td><td>([^<]+)</td>")
429
+ adsl.traffic_type = extract_value(html, r"Traffic Type:</td><td>([^<]+)</td>")
430
+ adsl.status = extract_value(html, r"Status:</td><td>([^<]+)</td>")
431
+ adsl.link_power_state = extract_value(
432
+ html, r"Link Power State:</td><td>([^<]+)</td>"
433
+ )
434
+
435
+ # Parse rate info - downstream and upstream
436
+ rate_match = re.search(
437
+ r"Rate \(Kbps\):</td><td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
438
+ )
439
+ if rate_match:
440
+ adsl.downstream_rate = int(rate_match.group(1))
441
+ adsl.upstream_rate = int(rate_match.group(2))
442
+
443
+ # Parse SNR margin
444
+ snr_match = re.search(
445
+ r"SNR Margin.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
446
+ )
447
+ if snr_match:
448
+ adsl.downstream_snr_margin = float(snr_match.group(1)) / 10
449
+ adsl.upstream_snr_margin = float(snr_match.group(2)) / 10
450
+
451
+ # Parse attenuation
452
+ atten_match = re.search(
453
+ r"Attenuation.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
454
+ )
455
+ if atten_match:
456
+ adsl.downstream_attenuation = float(atten_match.group(1)) / 10
457
+ adsl.upstream_attenuation = float(atten_match.group(2)) / 10
458
+
459
+ # Parse output power
460
+ power_match = re.search(
461
+ r"Output Power.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
462
+ )
463
+ if power_match:
464
+ adsl.downstream_output_power = float(power_match.group(1)) / 10
465
+ adsl.upstream_output_power = float(power_match.group(2)) / 10
466
+
467
+ # Parse attainable rate
468
+ attain_match = re.search(
469
+ r"Attainable Rate.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
470
+ )
471
+ if attain_match:
472
+ adsl.downstream_attainable_rate = int(attain_match.group(1))
473
+ adsl.upstream_attainable_rate = int(attain_match.group(2))
474
+
475
+ return adsl
476
+
477
+
478
+ def parse_logs(html: str) -> list[LogEntry]:
479
+ """Parse system logs from HTML.
480
+
481
+ Parses the /logview.cmd page to extract log entries with
482
+ timestamp, facility, severity, and message.
483
+ """
484
+ logs = []
485
+
486
+ # Find the log table
487
+ table_match = re.search(
488
+ r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
489
+ )
490
+ if not table_match:
491
+ return logs
492
+
493
+ table = table_match.group(0)
494
+
495
+ # Find data rows - 4 cells per row
496
+ rows = re.findall(
497
+ r"<tr>\s*"
498
+ r"<td[^>]*>([^<]*)</td>\s*"
499
+ r"<td[^>]*>([^<]*)</td>\s*"
500
+ r"<td[^>]*>([^<]*)</td>\s*"
501
+ r"<td[^>]*>([^<]*)</td>\s*"
502
+ r"</tr>",
503
+ table,
504
+ re.IGNORECASE,
505
+ )
506
+
507
+ for row in rows:
508
+ datetime_str, facility, severity, message = row
509
+ # Skip header row
510
+ if "Date/Time" in datetime_str:
511
+ continue
512
+ logs.append(
513
+ LogEntry(
514
+ datetime=datetime_str.strip(),
515
+ facility=facility.strip(),
516
+ severity=severity.strip(),
517
+ message=message.strip(),
518
+ )
519
+ )
520
+
521
+ return logs