arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

ocpp/store.py CHANGED
@@ -3,11 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import concurrent.futures
7
+ from collections import deque
8
+ from dataclasses import dataclass
6
9
  from datetime import datetime, timezone
7
10
  import json
11
+ import os
8
12
  from pathlib import Path
9
13
  import re
10
14
  import threading
15
+ import heapq
16
+ import itertools
17
+ from typing import Iterable, Iterator
11
18
 
12
19
  from core.log_paths import select_log_dir
13
20
 
@@ -19,7 +26,10 @@ MAX_CONNECTIONS_PER_IP = 2
19
26
 
20
27
  connections: dict[str, object] = {}
21
28
  transactions: dict[str, object] = {}
22
- logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
29
+ # Maximum number of recent log entries to keep in memory per identity.
30
+ MAX_IN_MEMORY_LOG_ENTRIES = 1000
31
+
32
+ logs: dict[str, dict[str, deque[str]]] = {"charger": {}, "simulator": {}}
23
33
  # store per charger session logs before they are flushed to disk
24
34
  history: dict[str, dict[str, object]] = {}
25
35
  simulators = {}
@@ -28,6 +38,7 @@ pending_calls: dict[str, dict[str, object]] = {}
28
38
  _pending_call_events: dict[str, threading.Event] = {}
29
39
  _pending_call_results: dict[str, dict[str, object]] = {}
30
40
  _pending_call_lock = threading.Lock()
41
+ _pending_call_handles: dict[str, asyncio.TimerHandle] = {}
31
42
  triggered_followups: dict[str, list[dict[str, object]]] = {}
32
43
 
33
44
  # mapping of charger id / cp_path to friendly names used for log files
@@ -42,6 +53,20 @@ LOCK_DIR.mkdir(exist_ok=True)
42
53
  SESSION_LOCK = LOCK_DIR / "charging.lck"
43
54
  _lock_task: asyncio.Task | None = None
44
55
 
56
+ _scheduler_loop: asyncio.AbstractEventLoop | None = None
57
+ _scheduler_thread: threading.Thread | None = None
58
+ _scheduler_lock = threading.Lock()
59
+
60
+ SESSION_LOG_BUFFER_LIMIT = 16
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class LogEntry:
65
+ """Structured log entry returned by :func:`iter_log_entries`."""
66
+
67
+ timestamp: datetime
68
+ text: str
69
+
45
70
 
46
71
  def connector_slug(value: int | str | None) -> str:
47
72
  """Return the canonical slug for a connector value."""
@@ -202,13 +227,20 @@ def register_pending_call(message_id: str, metadata: dict[str, object]) -> None:
202
227
  event = threading.Event()
203
228
  _pending_call_events[message_id] = event
204
229
  _pending_call_results.pop(message_id, None)
230
+ handle = _pending_call_handles.pop(message_id, None)
231
+ if handle:
232
+ _cancel_timer_handle(handle)
205
233
 
206
234
 
207
235
  def pop_pending_call(message_id: str) -> dict[str, object] | None:
208
236
  """Return and remove metadata for a previously registered call."""
209
237
 
210
238
  with _pending_call_lock:
211
- return pending_calls.pop(message_id, None)
239
+ metadata = pending_calls.pop(message_id, None)
240
+ handle = _pending_call_handles.pop(message_id, None)
241
+ if handle:
242
+ _cancel_timer_handle(handle)
243
+ return metadata
212
244
 
213
245
 
214
246
  def record_pending_call_result(
@@ -234,6 +266,9 @@ def record_pending_call_result(
234
266
  with _pending_call_lock:
235
267
  _pending_call_results[message_id] = result
236
268
  event = _pending_call_events.pop(message_id, None)
269
+ handle = _pending_call_handles.pop(message_id, None)
270
+ if handle:
271
+ _cancel_timer_handle(handle)
237
272
  if event:
238
273
  event.set()
239
274
 
@@ -269,29 +304,50 @@ def schedule_call_timeout(
269
304
  ) -> None:
270
305
  """Schedule a timeout notice if a pending call is not answered."""
271
306
 
307
+ loop = _ensure_scheduler_loop()
308
+
272
309
  def _notify() -> None:
310
+ target_log: str | None = None
311
+ entry_label: str | None = None
273
312
  with _pending_call_lock:
313
+ _pending_call_handles.pop(message_id, None)
274
314
  metadata = pending_calls.get(message_id)
275
- if not metadata:
276
- return
277
- if action and metadata.get("action") != action:
278
- return
279
- if metadata.get("timeout_notice_sent"):
280
- return
281
- target_log = log_key or metadata.get("log_key")
282
- if not target_log:
315
+ if not metadata:
316
+ return
317
+ if action and metadata.get("action") != action:
318
+ return
319
+ if metadata.get("timeout_notice_sent"):
320
+ return
321
+ target_log = log_key or metadata.get("log_key")
322
+ if not target_log:
323
+ metadata["timeout_notice_sent"] = True
324
+ return
325
+ entry_label = message
326
+ if not entry_label:
327
+ action_label = action or str(metadata.get("action") or "Call")
328
+ entry_label = f"{action_label} request timed out"
283
329
  metadata["timeout_notice_sent"] = True
330
+ if target_log and entry_label:
331
+ add_log(target_log, entry_label, log_type=log_type)
332
+
333
+ future: concurrent.futures.Future[asyncio.TimerHandle] = concurrent.futures.Future()
334
+
335
+ def _schedule_timer() -> None:
336
+ try:
337
+ handle = loop.call_later(timeout, _notify)
338
+ except Exception as exc: # pragma: no cover - defensive
339
+ future.set_exception(exc)
284
340
  return
285
- label = message
286
- if not label:
287
- action_label = action or str(metadata.get("action") or "Call")
288
- label = f"{action_label} request timed out"
289
- add_log(target_log, label, log_type=log_type)
290
- metadata["timeout_notice_sent"] = True
341
+ future.set_result(handle)
291
342
 
292
- timer = threading.Timer(timeout, _notify)
293
- timer.daemon = True
294
- timer.start()
343
+ loop.call_soon_threadsafe(_schedule_timer)
344
+ handle = future.result()
345
+
346
+ with _pending_call_lock:
347
+ previous = _pending_call_handles.pop(message_id, None)
348
+ _pending_call_handles[message_id] = handle
349
+ if previous:
350
+ _cancel_timer_handle(previous)
295
351
 
296
352
 
297
353
  def register_triggered_followup(
@@ -342,6 +398,7 @@ def consume_triggered_followup(
342
398
  def clear_pending_calls(serial: str) -> None:
343
399
  """Remove any pending calls associated with the provided charger id."""
344
400
 
401
+ to_cancel: list[asyncio.TimerHandle] = []
345
402
  with _pending_call_lock:
346
403
  to_remove = [
347
404
  key
@@ -352,7 +409,52 @@ def clear_pending_calls(serial: str) -> None:
352
409
  pending_calls.pop(key, None)
353
410
  _pending_call_events.pop(key, None)
354
411
  _pending_call_results.pop(key, None)
355
- triggered_followups.pop(serial, None)
412
+ handle = _pending_call_handles.pop(key, None)
413
+ if handle:
414
+ to_cancel.append(handle)
415
+ for handle in to_cancel:
416
+ _cancel_timer_handle(handle)
417
+
418
+
419
+ def _run_scheduler_loop(
420
+ loop: asyncio.AbstractEventLoop, ready: threading.Event
421
+ ) -> None:
422
+ asyncio.set_event_loop(loop)
423
+ ready.set()
424
+ loop.run_forever()
425
+
426
+
427
+ def _ensure_scheduler_loop() -> asyncio.AbstractEventLoop:
428
+ global _scheduler_loop, _scheduler_thread
429
+
430
+ loop = _scheduler_loop
431
+ if loop and loop.is_running():
432
+ return loop
433
+ with _scheduler_lock:
434
+ loop = _scheduler_loop
435
+ if loop and loop.is_running():
436
+ return loop
437
+ loop = asyncio.new_event_loop()
438
+ ready = threading.Event()
439
+ thread = threading.Thread(
440
+ target=_run_scheduler_loop,
441
+ args=(loop, ready),
442
+ name="ocpp-store-scheduler",
443
+ daemon=True,
444
+ )
445
+ thread.start()
446
+ ready.wait()
447
+ _scheduler_loop = loop
448
+ _scheduler_thread = thread
449
+ return loop
450
+
451
+
452
+ def _cancel_timer_handle(handle: asyncio.TimerHandle) -> None:
453
+ loop = _scheduler_loop
454
+ if loop and loop.is_running():
455
+ loop.call_soon_threadsafe(handle.cancel)
456
+ else: # pragma: no cover - loop stopped during shutdown
457
+ handle.cancel()
356
458
 
357
459
 
358
460
  def reassign_identity(old_key: str, new_key: str) -> str:
@@ -433,8 +535,21 @@ def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
433
535
  store = logs[log_type]
434
536
  # Store log entries under the cid as provided but allow retrieval using
435
537
  # any casing by recording entries in a case-insensitive manner.
436
- key = next((k for k in store.keys() if k.lower() == cid.lower()), cid)
437
- store.setdefault(key, []).append(entry)
538
+ buffer = None
539
+ lower = cid.lower()
540
+ key = cid
541
+ for existing_key, entries in store.items():
542
+ if existing_key.lower() == lower:
543
+ key = existing_key
544
+ buffer = entries
545
+ break
546
+ if buffer is None:
547
+ buffer = deque(maxlen=MAX_IN_MEMORY_LOG_ENTRIES)
548
+ store[key] = buffer
549
+ elif buffer.maxlen != MAX_IN_MEMORY_LOG_ENTRIES:
550
+ buffer = deque(buffer, maxlen=MAX_IN_MEMORY_LOG_ENTRIES)
551
+ store[key] = buffer
552
+ buffer.append(entry)
438
553
  path = _file_path(key, log_type)
439
554
  with path.open("a", encoding="utf-8") as handle:
440
555
  handle.write(entry + "\n")
@@ -452,10 +567,29 @@ def _session_folder(cid: str) -> Path:
452
567
  def start_session_log(cid: str, tx_id: int) -> None:
453
568
  """Begin logging a session for the given charger and transaction id."""
454
569
 
570
+ existing = history.pop(cid, None)
571
+ if existing:
572
+ try:
573
+ _finalize_session(existing)
574
+ except Exception:
575
+ # If finalizing the previous session fails we still want to reset
576
+ # the session metadata so the new session can proceed.
577
+ pass
578
+
579
+ start = datetime.now(timezone.utc)
580
+ folder = _session_folder(cid)
581
+ date = start.strftime("%Y%m%d")
582
+ filename = f"{date}_{tx_id}.json"
583
+ path = folder / filename
584
+ handle = path.open("w", encoding="utf-8")
585
+ handle.write("[")
455
586
  history[cid] = {
456
587
  "transaction": tx_id,
457
- "start": datetime.now(timezone.utc),
458
- "messages": [],
588
+ "start": start,
589
+ "path": path,
590
+ "handle": handle,
591
+ "buffer": [],
592
+ "first": True,
459
593
  }
460
594
 
461
595
 
@@ -465,14 +599,19 @@ def add_session_message(cid: str, message: str) -> None:
465
599
  sess = history.get(cid)
466
600
  if not sess:
467
601
  return
468
- sess["messages"].append(
602
+ buffer: list[str] = sess.setdefault("buffer", [])
603
+ payload = json.dumps(
469
604
  {
470
605
  "timestamp": datetime.now(timezone.utc)
471
606
  .isoformat()
472
607
  .replace("+00:00", "Z"),
473
608
  "message": message,
474
- }
609
+ },
610
+ ensure_ascii=False,
475
611
  )
612
+ buffer.append(payload)
613
+ if len(buffer) >= SESSION_LOG_BUFFER_LIMIT:
614
+ _flush_session_buffer(sess)
476
615
 
477
616
 
478
617
  def end_session_log(cid: str) -> None:
@@ -481,13 +620,42 @@ def end_session_log(cid: str) -> None:
481
620
  sess = history.pop(cid, None)
482
621
  if not sess:
483
622
  return
484
- folder = _session_folder(cid)
485
- date = sess["start"].strftime("%Y%m%d")
486
- tx_id = sess.get("transaction")
487
- filename = f"{date}_{tx_id}.json"
488
- path = folder / filename
489
- with path.open("w", encoding="utf-8") as handle:
490
- json.dump(sess["messages"], handle, ensure_ascii=False, indent=2)
623
+ _finalize_session(sess)
624
+
625
+
626
+ def _flush_session_buffer(sess: dict[str, object]) -> None:
627
+ handle = sess.get("handle")
628
+ buffer = sess.get("buffer")
629
+ if not handle or not buffer:
630
+ return
631
+ first = bool(sess.get("first", True))
632
+ for raw in list(buffer):
633
+ if not first:
634
+ handle.write(",")
635
+ handle.write("\n ")
636
+ handle.write(raw)
637
+ first = False
638
+ handle.flush()
639
+ buffer.clear()
640
+ sess["first"] = first
641
+
642
+
643
+ def _finalize_session(sess: dict[str, object]) -> None:
644
+ handle = sess.get("handle")
645
+ try:
646
+ _flush_session_buffer(sess)
647
+ if handle:
648
+ if sess.get("first", True):
649
+ handle.write("]\n")
650
+ else:
651
+ handle.write("\n]\n")
652
+ handle.flush()
653
+ finally:
654
+ if handle:
655
+ try:
656
+ handle.close()
657
+ except Exception:
658
+ pass
491
659
 
492
660
 
493
661
  def _log_key_candidates(cid: str, log_type: str) -> list[str]:
@@ -546,10 +714,17 @@ def _resolve_log_identifier(cid: str, log_type: str) -> tuple[str, str | None]:
546
714
  def _log_file_for_identifier(cid: str, name: str | None, log_type: str) -> Path:
547
715
  path = _file_path(cid, log_type)
548
716
  if not path.exists():
549
- target = f"{log_type}.{_safe_name(name or cid).lower()}"
550
- for file in LOG_DIR.glob(f"{log_type}.*.log"):
551
- if file.stem.lower() == target:
552
- path = file
717
+ candidates = [_safe_name(name or cid).lower()]
718
+ cid_candidate = _safe_name(cid).lower()
719
+ if cid_candidate not in candidates:
720
+ candidates.append(cid_candidate)
721
+ for candidate in candidates:
722
+ target = f"{log_type}.{candidate}"
723
+ for file in LOG_DIR.glob(f"{log_type}.*.log"):
724
+ if file.stem.lower() == target:
725
+ path = file
726
+ break
727
+ if path.exists():
553
728
  break
554
729
  return path
555
730
 
@@ -559,10 +734,194 @@ def _memory_logs_for_identifier(cid: str, log_type: str) -> list[str]:
559
734
  lower = cid.lower()
560
735
  for key, entries in store.items():
561
736
  if key.lower() == lower:
562
- return entries
737
+ return list(entries)
563
738
  return []
564
739
 
565
740
 
741
+ def _parse_log_timestamp(entry: str) -> datetime | None:
742
+ """Return the parsed timestamp for a log entry, if available."""
743
+
744
+ if len(entry) < 24:
745
+ return None
746
+ try:
747
+ timestamp = datetime.strptime(entry[:23], "%Y-%m-%d %H:%M:%S.%f")
748
+ except ValueError:
749
+ return None
750
+ return timestamp.replace(tzinfo=timezone.utc)
751
+
752
+
753
+ def _iter_file_lines_reverse(path: Path, *, limit: int | None = None) -> Iterator[str]:
754
+ """Yield lines from ``path`` starting with the newest entries."""
755
+
756
+ if not path.exists():
757
+ return
758
+
759
+ chunk_size = 4096
760
+ remaining = limit
761
+ with path.open("rb") as handle:
762
+ handle.seek(0, os.SEEK_END)
763
+ position = handle.tell()
764
+ buffer = b""
765
+ while position > 0:
766
+ read_size = min(chunk_size, position)
767
+ position -= read_size
768
+ handle.seek(position)
769
+ chunk = handle.read(read_size)
770
+ buffer = chunk + buffer
771
+ lines = buffer.split(b"\n")
772
+ buffer = lines.pop(0)
773
+ for line in reversed(lines):
774
+ if not line:
775
+ continue
776
+ try:
777
+ text = line.decode("utf-8")
778
+ except UnicodeDecodeError:
779
+ text = line.decode("utf-8", errors="ignore")
780
+ yield text
781
+ if remaining is not None:
782
+ remaining -= 1
783
+ if remaining <= 0:
784
+ return
785
+ if buffer:
786
+ try:
787
+ text = buffer.decode("utf-8")
788
+ except UnicodeDecodeError:
789
+ text = buffer.decode("utf-8", errors="ignore")
790
+ if text:
791
+ yield text
792
+
793
+
794
+ def _iter_log_entries_for_key(
795
+ cid: str,
796
+ name: str | None,
797
+ log_type: str,
798
+ *,
799
+ since: datetime | None = None,
800
+ limit: int | None = None,
801
+ ) -> Iterator[LogEntry]:
802
+ """Yield structured log entries for a specific identifier."""
803
+
804
+ yielded = 0
805
+ seen_for_key: set[str] = set()
806
+ memory_entries = _memory_logs_for_identifier(cid, log_type)
807
+ for entry in reversed(memory_entries):
808
+ if entry in seen_for_key:
809
+ continue
810
+ timestamp = _parse_log_timestamp(entry)
811
+ if timestamp is None:
812
+ continue
813
+ seen_for_key.add(entry)
814
+ yield LogEntry(timestamp=timestamp, text=entry)
815
+ yielded += 1
816
+ if since is not None and timestamp < since:
817
+ return
818
+ if limit is not None and yielded >= limit:
819
+ return
820
+
821
+ path = _log_file_for_identifier(cid, name, log_type)
822
+ file_limit = None
823
+ if limit is not None:
824
+ file_limit = max(limit - yielded, 0)
825
+ if file_limit == 0:
826
+ return
827
+ for entry in _iter_file_lines_reverse(path, limit=file_limit):
828
+ if entry in seen_for_key:
829
+ continue
830
+ timestamp = _parse_log_timestamp(entry)
831
+ if timestamp is None:
832
+ continue
833
+ seen_for_key.add(entry)
834
+ yield LogEntry(timestamp=timestamp, text=entry)
835
+ yielded += 1
836
+ if since is not None and timestamp < since:
837
+ return
838
+ if limit is not None and yielded >= limit:
839
+ return
840
+
841
+
842
+ def iter_log_entries(
843
+ identifiers: str | Iterable[str],
844
+ log_type: str = "charger",
845
+ *,
846
+ since: datetime | None = None,
847
+ limit: int | None = None,
848
+ ) -> Iterator[LogEntry]:
849
+ """Yield log entries ordered from newest to oldest.
850
+
851
+ ``identifiers`` may be a single charger identifier or an iterable of
852
+ identifiers. Results are de-duplicated across matching memory and file
853
+ sources and iteration stops once entries fall before ``since`` or ``limit``
854
+ is reached.
855
+ """
856
+
857
+ if isinstance(identifiers, str):
858
+ requested: list[str] = [identifiers]
859
+ else:
860
+ requested = list(identifiers)
861
+
862
+ seen_keys: set[str] = set()
863
+ sources: list[tuple[str, str | None]] = []
864
+ for identifier in requested:
865
+ for key in _log_key_candidates(identifier, log_type):
866
+ lower_key = key.lower()
867
+ if lower_key in seen_keys:
868
+ continue
869
+ seen_keys.add(lower_key)
870
+ resolved, name = _resolve_log_identifier(key, log_type)
871
+ sources.append((resolved, name))
872
+
873
+ heap: list[tuple[float, int, LogEntry, Iterator[LogEntry]]] = []
874
+ counter = itertools.count()
875
+ seen_entries: set[str] = set()
876
+ total_yielded = 0
877
+
878
+ for resolved, name in sources:
879
+ iterator = _iter_log_entries_for_key(
880
+ resolved,
881
+ name,
882
+ log_type,
883
+ since=since,
884
+ limit=limit,
885
+ )
886
+ try:
887
+ entry = next(iterator)
888
+ except StopIteration:
889
+ continue
890
+ heapq.heappush(
891
+ heap,
892
+ (
893
+ -entry.timestamp.timestamp(),
894
+ next(counter),
895
+ entry,
896
+ iterator,
897
+ ),
898
+ )
899
+
900
+ while heap:
901
+ _, _, entry, iterator = heapq.heappop(heap)
902
+ if entry.text not in seen_entries:
903
+ seen_entries.add(entry.text)
904
+ yield entry
905
+ total_yielded += 1
906
+ if limit is not None and total_yielded >= limit:
907
+ return
908
+ if since is not None and entry.timestamp < since:
909
+ return
910
+ try:
911
+ next_entry = next(iterator)
912
+ except StopIteration:
913
+ continue
914
+ heapq.heappush(
915
+ heap,
916
+ (
917
+ -next_entry.timestamp.timestamp(),
918
+ next(counter),
919
+ next_entry,
920
+ iterator,
921
+ ),
922
+ )
923
+
924
+
566
925
  def get_logs(cid: str, log_type: str = "charger") -> list[str]:
567
926
  """Return all log entries for the given id and type."""
568
927
 
@@ -573,14 +932,21 @@ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
573
932
  resolved, name = _resolve_log_identifier(key, log_type)
574
933
  path = _log_file_for_identifier(resolved, name, log_type)
575
934
  if path.exists() and path not in seen_paths:
576
- entries.extend(path.read_text(encoding="utf-8").splitlines())
935
+ if max_entries is None:
936
+ entries.extend(path.read_text(encoding="utf-8").splitlines())
937
+ else:
938
+ with path.open("r", encoding="utf-8") as handle:
939
+ for line in handle:
940
+ entries.append(line.rstrip("\r\n"))
577
941
  seen_paths.add(path)
578
942
  memory_entries = _memory_logs_for_identifier(resolved, log_type)
579
943
  lower_key = resolved.lower()
580
944
  if memory_entries and lower_key not in seen_keys:
581
945
  entries.extend(memory_entries)
582
946
  seen_keys.add(lower_key)
583
- return entries
947
+ if max_entries is None:
948
+ return entries_list
949
+ return list(entries_deque)
584
950
 
585
951
 
586
952
  def clear_log(cid: str, log_type: str = "charger") -> None: