stateman 1.3.0__py2.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.
stateman/__init__.py ADDED
@@ -0,0 +1,371 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import shutil # Imported but not used directly in this file? Maybe needed for tests or example.
5
+ from collections import OrderedDict
6
+ from pathlib import Path
7
+ from zipfile import ZipFile
8
+ from subprocess import Popen, PIPE # Used only in CheckGitRepo
9
+
10
+ # Define the path separator for the current OS for unification
11
+ sep = os.path.sep
12
+
13
+ def get_hash(filename):
14
+ """Calculates the MD5 hash of a file.
15
+
16
+ Reads the file in chunks for efficient handling of large files.
17
+
18
+ Args:
19
+ filename (str): Path to the file.
20
+
21
+ Returns:
22
+ str: The MD5 hash of the file as a hexadecimal string. Returns None if the file is not found.
23
+ """
24
+ hash_md5 = hashlib.md5()
25
+ try:
26
+ with open(filename, "rb") as f:
27
+ # Read the file in 4096-byte blocks
28
+ for chunk in iter(lambda: f.read(4096), b""):
29
+ hash_md5.update(chunk)
30
+ except FileNotFoundError:
31
+ # Handle the case where the file is not found (e.g., deleted between steps)
32
+ # Can return None, an empty string, or raise an exception depending on the logic
33
+ print(f"Warning: File not found during hashing: {filename}")
34
+ return None # Or another default value
35
+ return hash_md5.hexdigest()
36
+
37
+ def find_files(folder, exclude=None):
38
+ """Recursively finds all files in a directory and calculates their hashes.
39
+
40
+ Ignores files/directories whose path contains the `exclude` string.
41
+ Normalizes path separators to '/' for consistency across OS.
42
+
43
+ Args:
44
+ folder (str): The root directory to search.
45
+ exclude (str, optional): A string (or regex pattern), paths containing it will be excluded.
46
+ Defaults to None (nothing excluded).
47
+
48
+ Yields:
49
+ tuple: A tuple (relative_path, md5_hash) for each found file.
50
+ The relative path uses '/' as a separator.
51
+ """
52
+ if not folder.endswith(os.path.sep):
53
+ folder += os.path.sep
54
+
55
+ for root, dirs, files in os.walk(folder):
56
+ # Optionally: Exclude directories at the os.walk level for efficiency
57
+ if exclude:
58
+ dirs[:] = [d for d in dirs if exclude not in os.path.join(root, d)]
59
+
60
+ for file in files:
61
+ filename = os.path.join(root, file)
62
+ relative_path = filename.replace(folder, '').replace("\\", '/') # Path normalization
63
+
64
+ # Check for exclusion
65
+ if exclude and exclude in filename: # Check the full path for exclude
66
+ continue # Skip the file if it matches the exclusion pattern
67
+
68
+ file_hash = get_hash(filename)
69
+ if file_hash: # Ensure the hash was obtained (file wasn't deleted)
70
+ yield relative_path, file_hash
71
+
72
+
73
+ def GetState(folder, exclude=None):
74
+ """Creates a dictionary representing the state of a directory (file -> hash).
75
+
76
+ Uses find_files to get the list of files and their hashes.
77
+
78
+ Args:
79
+ folder (str): Path to the directory.
80
+ exclude (str, optional): Pattern to exclude files/directories.
81
+
82
+ Returns:
83
+ dict: A dictionary where keys are relative file paths (with '/' separator),
84
+ and values are their MD5 hashes.
85
+ """
86
+ return dict(find_files(folder, exclude))
87
+
88
+
89
+ def GetStateHash(state):
90
+ """Calculates a single MD5 hash for the entire directory state.
91
+
92
+ The hash depends on file names and their hashes. Sorting keys ensures
93
+ the same hash for the same state, regardless of file traversal order.
94
+
95
+ Args:
96
+ state (dict): The directory state dictionary (result of GetState).
97
+
98
+ Returns:
99
+ str: The overall MD5 hash of the state as a hexadecimal string.
100
+ """
101
+ hashid = hashlib.md5()
102
+ # Sort items by key (file path) for hash consistency
103
+ ordered = OrderedDict(sorted(state.items()))
104
+ for k, v in ordered.items():
105
+ hashid.update(k.encode('utf-8')) # Encode the key (path)
106
+ hashid.update(v.encode('utf-8')) # Encode the value (file hash)
107
+ return hashid.hexdigest()
108
+
109
+
110
+ def GetDiff(state1, state2):
111
+ """Calculates the difference between two directory states.
112
+
113
+ Determines added, removed, and changed files.
114
+
115
+ Args:
116
+ state1 (dict): The initial state (file -> hash dictionary).
117
+ state2 (dict): The final state (file -> hash dictionary).
118
+
119
+ Returns:
120
+ dict: A dictionary with difference information:
121
+ - 'removed': list[str] - list of removed files.
122
+ - 'added': list[str] - list of added files.
123
+ - 'changed': list[str] - list of changed files (different hashes).
124
+ - 'state': dict - the final state (state2).
125
+ - 'md5': dict - hash dictionary for added and changed files from state2.
126
+ - 'source_state': str - hash of the initial state (state1).
127
+ - 'target_state': str - hash of the final state (state2).
128
+ """
129
+ keysA = set(state1.keys())
130
+ keysB = set(state2.keys())
131
+
132
+ removed = keysA - keysB # Files present in state1 but not in state2
133
+ added = keysB - keysA # Files present in state2 but not in state1
134
+ keep = keysA.intersection(keysB) # Files present in both states
135
+
136
+ # Changed files are those present in both states but with different hashes
137
+ changed = [key for key in keep if state1[key] != state2[key]]
138
+
139
+ result = {
140
+ 'removed': list(removed),
141
+ 'added': list(added),
142
+ 'changed': list(changed),
143
+ 'state': state2, # Store the final state in the diff
144
+ 'md5': {}, # Hashes of files to be included in the patch
145
+ 'source_state': GetStateHash(state1), # Hash of the source state
146
+ 'target_state': GetStateHash(state2) # Hash of the target state
147
+ }
148
+
149
+ # Collect hashes for all files that were added or changed
150
+ allfiles_to_include = set(changed)
151
+ allfiles_to_include.update(added)
152
+ for key in allfiles_to_include:
153
+ result['md5'][key] = state2[key] # Take the hash from the final state
154
+
155
+ return result
156
+
157
+
158
+ def CreatePatch(source_folder, patch_file, diff):
159
+ """Creates a ZIP archive (patch) containing the changes.
160
+
161
+ The patch includes metadata (diff information) and the necessary files
162
+ (added and changed).
163
+
164
+ Args:
165
+ source_folder (str): The folder from which changed and added files are taken.
166
+ patch_file (str): The filename for the created ZIP patch.
167
+ diff (dict): The difference dictionary obtained from GetDiff.
168
+ """
169
+ with ZipFile(patch_file, "w") as z:
170
+ # Write metadata to metadata.json inside the archive
171
+ z.writestr("metadata.json", data=json.dumps(diff, indent=4, ensure_ascii=False))
172
+
173
+ # Add all added files to the archive
174
+ for file in diff.get('added', []):
175
+ source_path = os.path.join(source_folder, ClearPatch(file)) # Path to the file in the source folder
176
+ # arcname=file ensures the relative path is preserved inside the archive
177
+ z.write(source_path, arcname=file)
178
+
179
+ # Add all changed files to the archive
180
+ for file in diff.get('changed', []):
181
+ source_path = os.path.join(source_folder, ClearPatch(file)) # Path to the file in the source folder
182
+ z.write(source_path, arcname=file)
183
+
184
+
185
+ def ClearPatch(path):
186
+ """Normalizes path separators to be specific to the current OS.
187
+
188
+ Converts '/' to '\' on Windows and vice versa on other systems.
189
+ Used to construct correct file paths when reading/writing
190
+ in the file system, as paths within the diff and patch are stored with '/'.
191
+
192
+ Args:
193
+ path (str): Path with '/' separators.
194
+
195
+ Returns:
196
+ str: Path with separators specific to the current OS.
197
+ """
198
+ if sep == "\\": # If OS is Windows
199
+ return path.replace("/", "\\")
200
+ else: # For Linux, macOS, and other POSIX-compliant systems
201
+ return path.replace("\\", "/") # Replace backslashes just in case
202
+
203
+
204
+ def CheckGitRepo(target):
205
+ """Checks the integrity of a Git repository using 'git fsck'.
206
+
207
+ This function seems specific and possibly unrelated to the core
208
+ patching logic. Is it used anywhere?
209
+
210
+ Args:
211
+ target (str): Path to the directory assumed to be a Git repository.
212
+
213
+ Returns:
214
+ bool: True if 'git fsck' completed successfully (return code 0).
215
+
216
+ Raises:
217
+ Exception: If 'git fsck' failed or if the git command is not found.
218
+ """
219
+ try:
220
+ # Run 'git fsck' in the specified directory
221
+ process = Popen(["git", "fsck"], cwd=target, stdout=PIPE, stderr=PIPE, text=True, encoding='utf-8')
222
+ stdout, stderr = process.communicate()
223
+ code = process.wait()
224
+ if code != 0:
225
+ # If the command failed, raise an exception
226
+ raise Exception(f"Git fsck failed in {target}:\nSTDOUT: {stdout}\nSTDERR: {stderr}")
227
+ print(f"Git fsck successful in {target}")
228
+ return True
229
+ except FileNotFoundError:
230
+ raise Exception(f"Git command not found. Is Git installed and in PATH?")
231
+ except Exception as e:
232
+ # Catch other potential Popen errors
233
+ raise Exception(f"Error running git fsck in {target}: {e}")
234
+
235
+
236
+ def ApplyPatch(target, patch_file, exclude=None):
237
+ """Applies a patch to the target directory.
238
+
239
+ Verifies that the current state of the target directory matches
240
+ the source state specified in the patch. Deletes, adds, and updates files
241
+ according to the patch metadata.
242
+
243
+ Args:
244
+ target (str): Path to the target directory where the patch is applied.
245
+ patch_file (str): Path to the ZIP patch file.
246
+ exclude (str, optional): Pattern to exclude files when checking the
247
+ current state of the target directory.
248
+
249
+ Returns:
250
+ bool: True if the patch was successfully applied or if the directory
251
+ was already in the target state.
252
+
253
+ Raises:
254
+ FileNotFoundError: If the target directory or patch file does not exist.
255
+ ValueError: If the patch file is invalid (missing or corrupt metadata.json).
256
+ Exception: If the current state of the target directory does not match
257
+ the source state required by the patch.
258
+ AssertionError: If the hash of an extracted file does not match the hash
259
+ specified in the patch metadata (integrity check failure).
260
+ """
261
+ if not os.path.isdir(target):
262
+ raise FileNotFoundError(f"Target directory not found: {target}")
263
+ if not os.path.isfile(patch_file):
264
+ raise FileNotFoundError(f"Patch file not found: {patch_file}")
265
+
266
+ with ZipFile(patch_file, "r") as patch:
267
+ # Read metadata from the patch
268
+ try:
269
+ with patch.open('metadata.json', 'r') as metadata_file:
270
+ # Read as bytes and decode as utf-8 (more robust)
271
+ diff = json.loads(metadata_file.read().decode('utf-8'))
272
+ except KeyError:
273
+ raise ValueError("Invalid patch file: metadata.json not found.")
274
+ except json.JSONDecodeError:
275
+ raise ValueError("Invalid patch file: metadata.json is corrupted.")
276
+
277
+ print(f"Patch contains: Removed: {len(diff.get('removed',[]))}, Added: {len(diff.get('added',[]))}, Changed: {len(diff.get('changed',[]))}")
278
+
279
+ # Get the current state of the target directory
280
+ current_state = GetState(target, exclude)
281
+ state_hash = GetStateHash(current_state)
282
+
283
+ print(f"Current state hash: {state_hash}")
284
+ print(f"Patch source state hash: {diff.get('source_state')}")
285
+ print(f"Patch target state hash: {diff.get('target_state')}")
286
+
287
+ # Check if the patch needs to be applied at all
288
+ if diff.get('target_state') == state_hash:
289
+ print("Target directory is already in the target state. No action needed.")
290
+ return True
291
+
292
+ # Check if the current state matches the patch's source state
293
+ if diff.get('source_state') != state_hash:
294
+ print("Current state:", json.dumps(current_state, indent=2))
295
+ # print("Expected source state hash from patch:", diff.get('source_state'))
296
+ raise Exception("The current state of the target directory does not match the source state required by the patch.")
297
+
298
+ # --- Applying changes ---
299
+ print("Applying patch...")
300
+
301
+ # 1. Deleting files
302
+ for filename in diff.get('removed', []):
303
+ path_to_remove = ClearPatch(os.path.join(target, filename))
304
+ if os.path.isfile(path_to_remove):
305
+ try:
306
+ os.remove(path_to_remove)
307
+ print(f"- Removed: {path_to_remove}")
308
+ except OSError as e:
309
+ print(f"Warning: Could not remove file {path_to_remove}: {e}")
310
+ else:
311
+ print(f"Warning: File to remove not found (already removed?): {path_to_remove}")
312
+
313
+ # 2. Extracting/Updating files (added and changed)
314
+ files_to_extract = diff.get('added', []) + diff.get('changed', [])
315
+ patch_md5_map = diff.get('md5', {})
316
+
317
+ for filename in files_to_extract:
318
+ target_path = ClearPatch(os.path.join(target, filename))
319
+ target_file_dir = os.path.dirname(target_path)
320
+
321
+ # Create parent directories if they don't exist
322
+ Path(target_file_dir).mkdir(parents=True, exist_ok=True)
323
+
324
+ # Remove the old file if it exists (for changed files)
325
+ if filename in diff.get('changed', []) and os.path.exists(target_path):
326
+ try:
327
+ # Add a check to ensure it's a file, not a directory
328
+ if os.path.isfile(target_path):
329
+ os.remove(target_path)
330
+ else:
331
+ print(f"Warning: Expected file but found directory at {target_path}. Skipping removal.")
332
+ continue # Skip this file
333
+ except OSError as e:
334
+ print(f"Warning: Could not remove existing file {target_path} before update: {e}")
335
+ continue # Skip this file
336
+
337
+ try:
338
+ # Extract the file from the archive
339
+ patch.extract(filename, target)
340
+ action = "+" if filename in diff.get('added', []) else "*"
341
+ print(f"{action} Extracted: {target_path}")
342
+
343
+ # Verify the hash of the extracted file
344
+ extracted_hash = get_hash(target_path)
345
+ expected_hash = patch_md5_map.get(filename)
346
+
347
+ if not expected_hash:
348
+ print(f"Warning: No expected hash found in patch metadata for {filename}. Skipping check.")
349
+ elif not extracted_hash:
350
+ print(f"Warning: Could not calculate hash for extracted file {target_path} (likely deleted during process?). Skipping check.")
351
+ elif extracted_hash != expected_hash:
352
+ # If hashes don't match, it's a serious problem
353
+ raise AssertionError(
354
+ f"Hash mismatch for extracted file {target_path}! "
355
+ f"Expected {expected_hash}, got {extracted_hash}. Patch or extraction failed."
356
+ )
357
+
358
+ except KeyError:
359
+ print(f"Warning: File '{filename}' listed in patch metadata but not found in the archive.")
360
+ except Exception as e:
361
+ print(f"Error extracting or verifying file {filename}: {e}")
362
+ # Decide whether to stop the whole process or just skip the file
363
+ # raise e # Uncomment to stop patch application on error
364
+
365
+ print("Patch applied successfully.")
366
+ # Optional final check: hash of the state after patching should match target_state
367
+ final_state_hash = GetStateHash(GetState(target, exclude))
368
+ if final_state_hash != diff.get('target_state'):
369
+ print(f"Warning: Final state hash ({final_state_hash}) does not match patch target state hash ({diff.get('target_state')}). This might indicate issues during patching or with excluded files.")
370
+
371
+ return True
stateman/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ def main():
2
+ print("It's alive!")
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.3
2
+ Name: stateman
3
+ Version: 1.3.0
4
+ Summary: This module provides tools to track changes in the content of a directory and create compact binary patches that can be used to synchronize another copy of that directory.
5
+ License: MIT
6
+ Author: vpuhoff
7
+ Author-email: vpuhoff@live.ru
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: Utilities
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Stateman: Directory Binary Patching Utility
18
+
19
+ This module provides tools to track changes in the content of a directory and create compact binary "patches" that can be used to synchronize another copy of that directory.
20
+
21
+ ## Why is this needed?
22
+
23
+ Imagine you have a large directory with files (e.g., installed software, game assets, a dataset), and you need to distribute updates for this directory. Transferring the entire directory can be inefficient, especially if the changes affect only a small portion of the files.
24
+
25
+ `stateman` solves this problem by allowing you to:
26
+
27
+ 1. **Capture the state** of a directory at a specific point in time (list of files and their MD5 checksums).
28
+ 2. **Compare two states** (e.g., before and after an update) to determine which files were added, removed, or modified.
29
+ 3. **Create a binary patch** (a ZIP archive) containing only the added and modified files, along with metadata about the changes.
30
+ 4. **Apply the patch** to another copy of the directory (which is in the initial state) to bring it to the updated state.
31
+
32
+ This is useful for:
33
+ * Application or game update systems.
34
+ * Synchronizing large datasets between machines.
35
+ * Managing configurations where precise change tracking is needed.
36
+
37
+ ## How does it work?
38
+
39
+ The basic workflow is:
40
+
41
+ 1. **`GetState(folder)`**: Scans the specified folder, calculates the MD5 hash for each file, and returns a dictionary `{relative_path: hash}`. This is a "snapshot" of the folder's state.
42
+ 2. **`GetDiff(state1, state2)`**: Compares two such "snapshots" and returns a dictionary describing the difference: lists of added, removed, changed files, and the hashes of the source and target states.
43
+ 3. **`CreatePatch(source_folder, patch_file, difference)`**: Takes the difference dictionary (`diff`) and the files from `source_folder` that were added or changed, and packages them along with metadata into a ZIP archive (`patch_file`).
44
+ 4. **`ApplyPatch(target_folder, patch_file)`**: Unpacks the `patch_file`. First, it checks if the current state of `target_folder` matches the *source* state hash from the patch. If yes, it deletes files marked as removed and extracts/overwrites files from the archive. After applying, the state of `target_folder` should match the *target* state hash from the patch.
45
+
46
+ ## Usage Example:
47
+
48
+ ```python
49
+ from stateman import GetState, GetDiff, CreatePatch, ApplyPatch, GetStateHash
50
+ import os
51
+ import shutil
52
+ from pathlib import Path
53
+
54
+ # --- Setup ---
55
+ # Use temporary directories for the example
56
+ base_path = Path("./stateman_example_workspace")
57
+ if base_path.exists():
58
+ shutil.rmtree(base_path)
59
+ base_path.mkdir()
60
+
61
+ root_dir = base_path / "source_app" # Folder with the "original" application/data
62
+ target_dir = base_path / "user_app" # Folder where we will apply the patch
63
+ patch_file = base_path / "update.patch" # The patch file
64
+
65
+ root_dir.mkdir()
66
+ subfolder = root_dir / ".git" # Folder to be excluded
67
+ subfolder.mkdir()
68
+
69
+ def write_file(filepath, text):
70
+ """Helper function to write text to a file."""
71
+ filepath.parent.mkdir(parents=True, exist_ok=True)
72
+ filepath.write_text(text, encoding='utf-8')
73
+
74
+ print("--- 1. Creating initial files ---")
75
+ write_file(subfolder / "config", "git config")
76
+ write_file(root_dir / "main.py", "print('Hello')")
77
+ write_file(root_dir / "data" / "file1.txt", "data v1")
78
+ write_file(root_dir / "readme.txt", "Initial readme")
79
+
80
+ # --- Get initial state (excluding .git) ---
81
+ # Important to use os.path.sep for correct exclusion on different OS
82
+ exclude_pattern = f".git{os.path.sep}"
83
+ print(f"\n--- 2. Getting initial state (excluding '{exclude_pattern}') ---")
84
+ state1 = GetState(str(root_dir), exclude=exclude_pattern)
85
+ print("Initial state (State 1):")
86
+ print(state1)
87
+ state1_hash = GetStateHash(state1)
88
+ print(f"Initial state hash: {state1_hash}")
89
+
90
+ # --- Copy to simulate user's folder ---
91
+ print("\n--- 3. Copying initial state to target folder ---")
92
+ shutil.copytree(root_dir, target_dir, dirs_exist_ok=True)
93
+ print(f"Copied {root_dir} to {target_dir}")
94
+ # Verify that the target state matches
95
+ assert GetStateHash(GetState(str(target_dir), exclude=exclude_pattern)) == state1_hash
96
+
97
+ # --- Making changes in the source folder ---
98
+ print("\n--- 4. Making changes in the source folder ---")
99
+ os.remove(root_dir / "readme.txt") # Remove a file
100
+ write_file(root_dir / "data" / "file1.txt", "data v2") # Modify a file
101
+ write_file(root_dir / "new_module.py", "# New feature") # Add a file
102
+ print("- Removed readme.txt")
103
+ print("* Modified data/file1.txt")
104
+ print("+ Added new_module.py")
105
+
106
+ # --- Get the new state ---
107
+ print("\n--- 5. Getting new state after changes ---")
108
+ state2 = GetState(str(root_dir), exclude=exclude_pattern)
109
+ print("New state (State 2):")
110
+ print(state2)
111
+ state2_hash = GetStateHash(state2)
112
+ print(f"New state hash: {state2_hash}")
113
+
114
+ # --- Calculate the difference ---
115
+ print("\n--- 6. Calculating the difference between states ---")
116
+ diff = GetDiff(state1, state2)
117
+ print("Difference (Diff):")
118
+ # Print only keys for brevity
119
+ print(f" Removed: {diff['removed']}")
120
+ print(f" Added: {diff['added']}")
121
+ print(f" Changed: {diff['changed']}")
122
+ print(f" Source State Hash: {diff['source_state']}")
123
+ print(f" Target State Hash: {diff['target_state']}")
124
+ # Verify state hashes in the diff
125
+ assert diff['source_state'] == state1_hash
126
+ assert diff['target_state'] == state2_hash
127
+
128
+ # --- Create the patch ---
129
+ print(f"\n--- 7. Creating the patch file: {patch_file} ---")
130
+ CreatePatch(str(root_dir), str(patch_file), diff)
131
+ print(f"Patch created.")
132
+ assert patch_file.exists()
133
+
134
+ # --- Apply the patch ---
135
+ print(f"\n--- 8. Applying the patch to the target folder: {target_dir} ---")
136
+ # Before applying, ensure the target state is still state1
137
+ assert GetStateHash(GetState(str(target_dir), exclude=exclude_pattern)) == state1_hash
138
+ ApplyPatch(str(target_dir), str(patch_file), exclude=exclude_pattern)
139
+
140
+ # --- Verify the result ---
141
+ print("\n--- 9. Verifying target folder state after patch ---")
142
+ state_target_after = GetState(str(target_dir), exclude=exclude_pattern)
143
+ state_target_after_hash = GetStateHash(state_target_after)
144
+ print("Target folder state after patch:")
145
+ print(state_target_after)
146
+ print(f"Target folder state hash after patch: {state_target_after_hash}")
147
+
148
+ # The target folder state should now match state2
149
+ assert state_target_after == state2, "Target folder state does not match expected State 2"
150
+ assert state_target_after_hash == state2_hash, "Target folder state hash does not match expected State 2 hash"
151
+ print("\nSuccess! Target folder successfully updated using the patch.")
152
+
153
+ # --- Cleanup ---
154
+ # shutil.rmtree(base_path)
155
+ # print(f"\nWorkspace folder {base_path} removed.")
156
+
157
+ ````
158
+
159
+ ## Testing
160
+
161
+ The module includes a test suite (`tests.py`) using `pytest`. To run the tests, execute `pytest` in the project's root folder.
162
+
163
+ ## Source Code:
164
+
165
+ * [https://github.com/vpuhoff/stateman](https://github.com/vpuhoff/stateman)
@@ -0,0 +1,6 @@
1
+ stateman/__init__.py,sha256=izjdhCsim5EXx1Qjc_GGCebIPmbKD4Q5M4uu9cIol40,15969
2
+ stateman/__main__.py,sha256=RFbzlklze7e3I_HqWDLSfQmTT7yKdCAwqTZkAR3MhRo,36
3
+ stateman-1.3.0.dist-info/METADATA,sha256=16P0iO-a-uhyZGNrhmlqQrw6YqdsgLAoWJQMzewionY,7505
4
+ stateman-1.3.0.dist-info/WHEEL,sha256=9jVypgI8LevED4l0j3YDHs5IZdbkcE7aWw9FT9n2UzI,92
5
+ stateman-1.3.0.dist-info/entry_points.txt,sha256=UDyIc0OTuPTUG1KDXJ_RoSzC9uaGAfuNI3HoLQ6tI48,40
6
+ stateman-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.2
3
+ Root-Is-Purelib: true
4
+ Tag: py2.py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ keylocker=entry:main
3
+