grz-cli 0.1.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,170 @@
1
+ """Module for tracking and logging the state of files over time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import typing
8
+ from os import PathLike
9
+ from pathlib import Path
10
+
11
+ from .file_operations import read_multiple_json
12
+ from .models.v1_0_0.metadata import File as SubmissionFileMetadata
13
+
14
+ Index = tuple[str, float, int]
15
+ State = dict
16
+
17
+
18
+ class FileProgressLogger:
19
+ """
20
+ A class to log and track the state of files over time. It stores file states in a log file of JSON entries
21
+ and allows querying the state based on the file path and modification time.
22
+ """
23
+
24
+ _index = {"file_path": str, "modification_time": float, "size": int}
25
+ # mapping of index -> (metadata, data)
26
+ _file_states: dict[Index, tuple[SubmissionFileMetadata, State]]
27
+
28
+ def __init__(self, log_file_path: str | PathLike):
29
+ """
30
+ Initializes the FileProgressLogger instance.
31
+
32
+ :param fields: Dictionary of field names and their types, representing the state to track.
33
+ :param log_file_path: Path to the JSON file where file progress will be logged.
34
+ """
35
+ self._file_path = Path(log_file_path)
36
+ self._file_states = {}
37
+
38
+ # Read existing file states from the log file
39
+ self.read()
40
+
41
+ def read(self):
42
+ """
43
+ Reads the log file and loads the file states into memory.
44
+
45
+ :raises ValueError: If the path exists but is not a file.
46
+ """
47
+ if self._file_path.exists():
48
+ if self._file_path.is_file():
49
+ with open(self._file_path) as fd:
50
+ for row_dict in read_multiple_json(fd):
51
+ # Get index and cast them to the correct types
52
+ index = typing.cast(
53
+ Index,
54
+ tuple(self._index[k](row_dict[k]) for k in self._index),
55
+ )
56
+ # Get metadata
57
+ metadata = row_dict["metadata"]
58
+ # Get state
59
+ state = row_dict["state"]
60
+
61
+ self._file_states[index] = (metadata, state)
62
+ else:
63
+ raise ValueError(f"Path is not a file: '{str(self._file_path)}'")
64
+
65
+ def cleanup(self, keep: list[tuple[PathLike, SubmissionFileMetadata]]):
66
+ """
67
+ Removes all entries from the log file and in-memory state that are not in the keep list.
68
+
69
+ :param keep: List of tuples containing the file path and metadata of files to keep.
70
+ """
71
+ self._file_path.unlink(missing_ok=True)
72
+ for file, file_metadata in keep:
73
+ state = self.get_state(file, file_metadata)
74
+ if state is not None:
75
+ self.set_state(file, file_metadata, state)
76
+
77
+ def _get_index(self, file_path: str | PathLike) -> Index:
78
+ """
79
+ Generates a unique index for a given file based on its name and modification time.
80
+
81
+ :param file_path: Path object representing the file.
82
+ :return: A tuple containing the file name and modification time.
83
+ """
84
+ file_path = Path(file_path).resolve()
85
+
86
+ if file_path.is_file():
87
+ return str(file_path), file_path.stat().st_mtime, file_path.stat().st_size
88
+ else:
89
+ return str(file_path), -1, -1 # catches files that do not exist
90
+
91
+ # def get_index(self, file_path: Path, file_metadata: Dict) -> tuple:
92
+ # return self._get_index(file_path, file_metadata)
93
+
94
+ def get_state(
95
+ self,
96
+ file_path: str | PathLike,
97
+ file_metadata: dict | SubmissionFileMetadata,
98
+ ) -> State | None:
99
+ """
100
+ Retrieves the stored state of a file if it exists in the log.
101
+
102
+ :param file_path: The file path to query for the state.
103
+ :param file_metadata: The metadata of the file to query for the state.
104
+ :return: A dictionary representing the file's state, or None if the file's state isn't logged.
105
+ """
106
+ file_path = Path(file_path)
107
+ index = self._get_index(file_path)
108
+
109
+ # get stored state
110
+ stored_metadata, stored_data = self._file_states.get(index, (None, None))
111
+
112
+ # check if metadata matches
113
+ if file_metadata and not isinstance(file_metadata, SubmissionFileMetadata):
114
+ file_metadata = SubmissionFileMetadata(**file_metadata)
115
+
116
+ if stored_metadata and not isinstance(stored_metadata, SubmissionFileMetadata):
117
+ stored_metadata = SubmissionFileMetadata(**stored_metadata)
118
+
119
+ if stored_metadata and file_metadata == stored_metadata:
120
+ return copy.deepcopy(stored_data)
121
+ # metadata mismatch -> no valid stored state -> return None
122
+ return None
123
+
124
+ def set_state(
125
+ self,
126
+ file_path: str | PathLike,
127
+ file_metadata: dict | SubmissionFileMetadata,
128
+ state: State,
129
+ ):
130
+ """
131
+ Log the state of a file:
132
+ - Update the in-memory state
133
+ - Persist the state to the JSON log file
134
+
135
+ :param file_path: The path of the file whose state is being set.
136
+ :param file_metadata: Submission file metadata to store
137
+ :param state: A dictionary containing the file's state data to be logged.
138
+ """
139
+ file_path = Path(file_path)
140
+ index = self._get_index(file_path)
141
+
142
+ if file_metadata and not isinstance(file_metadata, SubmissionFileMetadata):
143
+ file_metadata = SubmissionFileMetadata(**file_metadata)
144
+ file_metadata = typing.cast(SubmissionFileMetadata, file_metadata)
145
+
146
+ # Update state in memory
147
+ self._file_states[index] = (file_metadata, state)
148
+
149
+ # Persist state to JSON log file
150
+ with open(self._file_path, "a", newline="") as fd:
151
+ # Append the new state row to the log file
152
+ json.dump(
153
+ {
154
+ # index keys
155
+ **{k: v for k, v in zip(self._index.keys(), index, strict=True)},
156
+ # state
157
+ "metadata": file_metadata.model_dump(by_alias=True),
158
+ "state": state,
159
+ },
160
+ fd,
161
+ )
162
+ fd.write("\n")
163
+
164
+ def num_entries(self) -> int:
165
+ """
166
+ Returns the number of entries in the file_states dictionary
167
+
168
+ :return: An integer representing the number of entries in the file_states dictionary
169
+ """
170
+ return len(self._file_states)