stateman 1.2.0__tar.gz → 1.3.0__tar.gz
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-1.3.0/PKG-INFO +165 -0
- stateman-1.3.0/README.md +149 -0
- stateman-1.3.0/pyproject.toml +26 -0
- stateman-1.3.0/stateman/__init__.py +371 -0
- stateman-1.2.0/PKG-INFO +0 -84
- stateman-1.2.0/README.md +0 -68
- stateman-1.2.0/pyproject.toml +0 -19
- stateman-1.2.0/setup.py +0 -30
- stateman-1.2.0/stateman/__init__.py +0 -131
- {stateman-1.2.0 → stateman-1.3.0}/stateman/__main__.py +0 -0
stateman-1.3.0/PKG-INFO
ADDED
|
@@ -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)
|
stateman-1.3.0/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Stateman: Directory Binary Patching Utility
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Why is this needed?
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
`stateman` solves this problem by allowing you to:
|
|
10
|
+
|
|
11
|
+
1. **Capture the state** of a directory at a specific point in time (list of files and their MD5 checksums).
|
|
12
|
+
2. **Compare two states** (e.g., before and after an update) to determine which files were added, removed, or modified.
|
|
13
|
+
3. **Create a binary patch** (a ZIP archive) containing only the added and modified files, along with metadata about the changes.
|
|
14
|
+
4. **Apply the patch** to another copy of the directory (which is in the initial state) to bring it to the updated state.
|
|
15
|
+
|
|
16
|
+
This is useful for:
|
|
17
|
+
* Application or game update systems.
|
|
18
|
+
* Synchronizing large datasets between machines.
|
|
19
|
+
* Managing configurations where precise change tracking is needed.
|
|
20
|
+
|
|
21
|
+
## How does it work?
|
|
22
|
+
|
|
23
|
+
The basic workflow is:
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
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.
|
|
27
|
+
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`).
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
## Usage Example:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from stateman import GetState, GetDiff, CreatePatch, ApplyPatch, GetStateHash
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
# --- Setup ---
|
|
39
|
+
# Use temporary directories for the example
|
|
40
|
+
base_path = Path("./stateman_example_workspace")
|
|
41
|
+
if base_path.exists():
|
|
42
|
+
shutil.rmtree(base_path)
|
|
43
|
+
base_path.mkdir()
|
|
44
|
+
|
|
45
|
+
root_dir = base_path / "source_app" # Folder with the "original" application/data
|
|
46
|
+
target_dir = base_path / "user_app" # Folder where we will apply the patch
|
|
47
|
+
patch_file = base_path / "update.patch" # The patch file
|
|
48
|
+
|
|
49
|
+
root_dir.mkdir()
|
|
50
|
+
subfolder = root_dir / ".git" # Folder to be excluded
|
|
51
|
+
subfolder.mkdir()
|
|
52
|
+
|
|
53
|
+
def write_file(filepath, text):
|
|
54
|
+
"""Helper function to write text to a file."""
|
|
55
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
filepath.write_text(text, encoding='utf-8')
|
|
57
|
+
|
|
58
|
+
print("--- 1. Creating initial files ---")
|
|
59
|
+
write_file(subfolder / "config", "git config")
|
|
60
|
+
write_file(root_dir / "main.py", "print('Hello')")
|
|
61
|
+
write_file(root_dir / "data" / "file1.txt", "data v1")
|
|
62
|
+
write_file(root_dir / "readme.txt", "Initial readme")
|
|
63
|
+
|
|
64
|
+
# --- Get initial state (excluding .git) ---
|
|
65
|
+
# Important to use os.path.sep for correct exclusion on different OS
|
|
66
|
+
exclude_pattern = f".git{os.path.sep}"
|
|
67
|
+
print(f"\n--- 2. Getting initial state (excluding '{exclude_pattern}') ---")
|
|
68
|
+
state1 = GetState(str(root_dir), exclude=exclude_pattern)
|
|
69
|
+
print("Initial state (State 1):")
|
|
70
|
+
print(state1)
|
|
71
|
+
state1_hash = GetStateHash(state1)
|
|
72
|
+
print(f"Initial state hash: {state1_hash}")
|
|
73
|
+
|
|
74
|
+
# --- Copy to simulate user's folder ---
|
|
75
|
+
print("\n--- 3. Copying initial state to target folder ---")
|
|
76
|
+
shutil.copytree(root_dir, target_dir, dirs_exist_ok=True)
|
|
77
|
+
print(f"Copied {root_dir} to {target_dir}")
|
|
78
|
+
# Verify that the target state matches
|
|
79
|
+
assert GetStateHash(GetState(str(target_dir), exclude=exclude_pattern)) == state1_hash
|
|
80
|
+
|
|
81
|
+
# --- Making changes in the source folder ---
|
|
82
|
+
print("\n--- 4. Making changes in the source folder ---")
|
|
83
|
+
os.remove(root_dir / "readme.txt") # Remove a file
|
|
84
|
+
write_file(root_dir / "data" / "file1.txt", "data v2") # Modify a file
|
|
85
|
+
write_file(root_dir / "new_module.py", "# New feature") # Add a file
|
|
86
|
+
print("- Removed readme.txt")
|
|
87
|
+
print("* Modified data/file1.txt")
|
|
88
|
+
print("+ Added new_module.py")
|
|
89
|
+
|
|
90
|
+
# --- Get the new state ---
|
|
91
|
+
print("\n--- 5. Getting new state after changes ---")
|
|
92
|
+
state2 = GetState(str(root_dir), exclude=exclude_pattern)
|
|
93
|
+
print("New state (State 2):")
|
|
94
|
+
print(state2)
|
|
95
|
+
state2_hash = GetStateHash(state2)
|
|
96
|
+
print(f"New state hash: {state2_hash}")
|
|
97
|
+
|
|
98
|
+
# --- Calculate the difference ---
|
|
99
|
+
print("\n--- 6. Calculating the difference between states ---")
|
|
100
|
+
diff = GetDiff(state1, state2)
|
|
101
|
+
print("Difference (Diff):")
|
|
102
|
+
# Print only keys for brevity
|
|
103
|
+
print(f" Removed: {diff['removed']}")
|
|
104
|
+
print(f" Added: {diff['added']}")
|
|
105
|
+
print(f" Changed: {diff['changed']}")
|
|
106
|
+
print(f" Source State Hash: {diff['source_state']}")
|
|
107
|
+
print(f" Target State Hash: {diff['target_state']}")
|
|
108
|
+
# Verify state hashes in the diff
|
|
109
|
+
assert diff['source_state'] == state1_hash
|
|
110
|
+
assert diff['target_state'] == state2_hash
|
|
111
|
+
|
|
112
|
+
# --- Create the patch ---
|
|
113
|
+
print(f"\n--- 7. Creating the patch file: {patch_file} ---")
|
|
114
|
+
CreatePatch(str(root_dir), str(patch_file), diff)
|
|
115
|
+
print(f"Patch created.")
|
|
116
|
+
assert patch_file.exists()
|
|
117
|
+
|
|
118
|
+
# --- Apply the patch ---
|
|
119
|
+
print(f"\n--- 8. Applying the patch to the target folder: {target_dir} ---")
|
|
120
|
+
# Before applying, ensure the target state is still state1
|
|
121
|
+
assert GetStateHash(GetState(str(target_dir), exclude=exclude_pattern)) == state1_hash
|
|
122
|
+
ApplyPatch(str(target_dir), str(patch_file), exclude=exclude_pattern)
|
|
123
|
+
|
|
124
|
+
# --- Verify the result ---
|
|
125
|
+
print("\n--- 9. Verifying target folder state after patch ---")
|
|
126
|
+
state_target_after = GetState(str(target_dir), exclude=exclude_pattern)
|
|
127
|
+
state_target_after_hash = GetStateHash(state_target_after)
|
|
128
|
+
print("Target folder state after patch:")
|
|
129
|
+
print(state_target_after)
|
|
130
|
+
print(f"Target folder state hash after patch: {state_target_after_hash}")
|
|
131
|
+
|
|
132
|
+
# The target folder state should now match state2
|
|
133
|
+
assert state_target_after == state2, "Target folder state does not match expected State 2"
|
|
134
|
+
assert state_target_after_hash == state2_hash, "Target folder state hash does not match expected State 2 hash"
|
|
135
|
+
print("\nSuccess! Target folder successfully updated using the patch.")
|
|
136
|
+
|
|
137
|
+
# --- Cleanup ---
|
|
138
|
+
# shutil.rmtree(base_path)
|
|
139
|
+
# print(f"\nWorkspace folder {base_path} removed.")
|
|
140
|
+
|
|
141
|
+
````
|
|
142
|
+
|
|
143
|
+
## Testing
|
|
144
|
+
|
|
145
|
+
The module includes a test suite (`tests.py`) using `pytest`. To run the tests, execute `pytest` in the project's root folder.
|
|
146
|
+
|
|
147
|
+
## Source Code:
|
|
148
|
+
|
|
149
|
+
* [https://github.com/vpuhoff/stateman](https://github.com/vpuhoff/stateman)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "stateman"
|
|
3
|
+
version = "1.3.0" # Убедитесь, что версия актуальна
|
|
4
|
+
description = "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
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "vpuhoff", email = "vpuhoff@live.ru" }
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Development Status :: 4 - Beta", # Примерный статус
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
17
|
+
"Topic :: Utilities",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
keylocker = "entry:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["poetry-core>=1.0.0"]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -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-1.2.0/PKG-INFO
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: stateman
|
|
3
|
-
Version: 1.2.0
|
|
4
|
-
Summary: Creating binary patch files containing changes in the directory
|
|
5
|
-
License: MIT
|
|
6
|
-
Author: pukhov-vi
|
|
7
|
-
Author-email: vipukhov@sberbank.ru
|
|
8
|
-
Requires-Python: >=3.6,<4.0
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.6
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
|
|
17
|
-
Creating binary patch files containing changes in the directory
|
|
18
|
-
|
|
19
|
-
## Simple usage :
|
|
20
|
-
```
|
|
21
|
-
from stateman import GetState, GetDiff, CreatePatch, ApplyPatch
|
|
22
|
-
import os
|
|
23
|
-
import shutil
|
|
24
|
-
patch_file="new.patch"
|
|
25
|
-
|
|
26
|
-
def write_file(filename, text):
|
|
27
|
-
with open(filename,'w') as f:
|
|
28
|
-
f.write(text)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# Clear workspace
|
|
32
|
-
if os.path.exists(".tmp"):
|
|
33
|
-
shutil.rmtree(".tmp")
|
|
34
|
-
if os.path.exists(".tmp2"):
|
|
35
|
-
shutil.rmtree(".tmp2")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# Init workspace .tmp with one subfolder
|
|
39
|
-
root_dir = os.path.abspath(".tmp")
|
|
40
|
-
target_dir = os.path.abspath(".tmp2")
|
|
41
|
-
os.makedirs(".tmp")
|
|
42
|
-
subfolder = os.path.join(root_dir,".git")
|
|
43
|
-
os.makedirs(subfolder)
|
|
44
|
-
|
|
45
|
-
# Create files in workspace
|
|
46
|
-
write_file(os.path.join(subfolder,"testfile1.txt"), "test1")
|
|
47
|
-
write_file(os.path.join(subfolder,"testfile2.txt"), "test2")
|
|
48
|
-
write_file(os.path.join(root_dir,"testfile3.txt"), "test3")
|
|
49
|
-
write_file(os.path.join(root_dir,"testfile4.txt"), "test4")
|
|
50
|
-
|
|
51
|
-
# Get state of workspace
|
|
52
|
-
state1 = GetState(root_dir, exclude = r'.git'+os.path.sep)
|
|
53
|
-
print(state1)
|
|
54
|
-
|
|
55
|
-
# Make a copy of workspace
|
|
56
|
-
shutil.copytree(root_dir, target_dir)
|
|
57
|
-
|
|
58
|
-
# Make a changes
|
|
59
|
-
os.remove(os.path.join(subfolder,"testfile2.txt"))
|
|
60
|
-
os.remove(os.path.join(root_dir,"testfile4.txt"))
|
|
61
|
-
write_file(os.path.join(subfolder,"testfile2.txt"), "changed")
|
|
62
|
-
|
|
63
|
-
# Get state of workspace after changes
|
|
64
|
-
state2 = GetState(root_dir, exclude = r'.git'+os.path.sep)
|
|
65
|
-
print(state2)
|
|
66
|
-
|
|
67
|
-
# Get diff between spaces
|
|
68
|
-
diff = GetDiff(state1, state2)
|
|
69
|
-
print (diff)
|
|
70
|
-
|
|
71
|
-
# Create patch file
|
|
72
|
-
CreatePatch(root_dir, patch_file, diff)
|
|
73
|
-
|
|
74
|
-
# Apply patch on copy of workspace, maked before changes
|
|
75
|
-
ApplyPatch(target_dir, patch_file, exclude = r'.git'+os.path.sep)
|
|
76
|
-
|
|
77
|
-
# Copy of workspace after apply patch identical as workspace
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Source Code:
|
|
81
|
-
* [https://github.com/vpuhoff/stateman](https://github.com/vpuhoff/stateman)
|
|
82
|
-
|
|
83
|
-
## Travis CI Deploys:
|
|
84
|
-
* [https://travis-ci.com/vpuhoff/patchgen](https://travis-ci.com/vpuhoff/stateman) [](https://travis-ci.com/vpuhoff/stateman)
|
stateman-1.2.0/README.md
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
Creating binary patch files containing changes in the directory
|
|
2
|
-
|
|
3
|
-
## Simple usage :
|
|
4
|
-
```
|
|
5
|
-
from stateman import GetState, GetDiff, CreatePatch, ApplyPatch
|
|
6
|
-
import os
|
|
7
|
-
import shutil
|
|
8
|
-
patch_file="new.patch"
|
|
9
|
-
|
|
10
|
-
def write_file(filename, text):
|
|
11
|
-
with open(filename,'w') as f:
|
|
12
|
-
f.write(text)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
# Clear workspace
|
|
16
|
-
if os.path.exists(".tmp"):
|
|
17
|
-
shutil.rmtree(".tmp")
|
|
18
|
-
if os.path.exists(".tmp2"):
|
|
19
|
-
shutil.rmtree(".tmp2")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Init workspace .tmp with one subfolder
|
|
23
|
-
root_dir = os.path.abspath(".tmp")
|
|
24
|
-
target_dir = os.path.abspath(".tmp2")
|
|
25
|
-
os.makedirs(".tmp")
|
|
26
|
-
subfolder = os.path.join(root_dir,".git")
|
|
27
|
-
os.makedirs(subfolder)
|
|
28
|
-
|
|
29
|
-
# Create files in workspace
|
|
30
|
-
write_file(os.path.join(subfolder,"testfile1.txt"), "test1")
|
|
31
|
-
write_file(os.path.join(subfolder,"testfile2.txt"), "test2")
|
|
32
|
-
write_file(os.path.join(root_dir,"testfile3.txt"), "test3")
|
|
33
|
-
write_file(os.path.join(root_dir,"testfile4.txt"), "test4")
|
|
34
|
-
|
|
35
|
-
# Get state of workspace
|
|
36
|
-
state1 = GetState(root_dir, exclude = r'.git'+os.path.sep)
|
|
37
|
-
print(state1)
|
|
38
|
-
|
|
39
|
-
# Make a copy of workspace
|
|
40
|
-
shutil.copytree(root_dir, target_dir)
|
|
41
|
-
|
|
42
|
-
# Make a changes
|
|
43
|
-
os.remove(os.path.join(subfolder,"testfile2.txt"))
|
|
44
|
-
os.remove(os.path.join(root_dir,"testfile4.txt"))
|
|
45
|
-
write_file(os.path.join(subfolder,"testfile2.txt"), "changed")
|
|
46
|
-
|
|
47
|
-
# Get state of workspace after changes
|
|
48
|
-
state2 = GetState(root_dir, exclude = r'.git'+os.path.sep)
|
|
49
|
-
print(state2)
|
|
50
|
-
|
|
51
|
-
# Get diff between spaces
|
|
52
|
-
diff = GetDiff(state1, state2)
|
|
53
|
-
print (diff)
|
|
54
|
-
|
|
55
|
-
# Create patch file
|
|
56
|
-
CreatePatch(root_dir, patch_file, diff)
|
|
57
|
-
|
|
58
|
-
# Apply patch on copy of workspace, maked before changes
|
|
59
|
-
ApplyPatch(target_dir, patch_file, exclude = r'.git'+os.path.sep)
|
|
60
|
-
|
|
61
|
-
# Copy of workspace after apply patch identical as workspace
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## Source Code:
|
|
65
|
-
* [https://github.com/vpuhoff/stateman](https://github.com/vpuhoff/stateman)
|
|
66
|
-
|
|
67
|
-
## Travis CI Deploys:
|
|
68
|
-
* [https://travis-ci.com/vpuhoff/patchgen](https://travis-ci.com/vpuhoff/stateman) [](https://travis-ci.com/vpuhoff/stateman)
|
stateman-1.2.0/pyproject.toml
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "stateman"
|
|
3
|
-
version = "1.2.0"
|
|
4
|
-
description = "Creating binary patch files containing changes in the directory"
|
|
5
|
-
authors = ["pukhov-vi <vipukhov@sberbank.ru>"]
|
|
6
|
-
license = "MIT"
|
|
7
|
-
readme = "README.md"
|
|
8
|
-
|
|
9
|
-
[tool.poetry.dependencies]
|
|
10
|
-
python = "^3.6"
|
|
11
|
-
|
|
12
|
-
[tool.poetry.dev-dependencies]
|
|
13
|
-
|
|
14
|
-
[build-system]
|
|
15
|
-
requires = ["poetry-core>=1.0.0"]
|
|
16
|
-
build-backend = "poetry.core.masonry.api"
|
|
17
|
-
|
|
18
|
-
[tool.poetry.scripts]
|
|
19
|
-
APPLICATION-NAME = 'entry:main'
|
stateman-1.2.0/setup.py
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
from setuptools import setup
|
|
3
|
-
|
|
4
|
-
packages = \
|
|
5
|
-
['stateman']
|
|
6
|
-
|
|
7
|
-
package_data = \
|
|
8
|
-
{'': ['*']}
|
|
9
|
-
|
|
10
|
-
entry_points = \
|
|
11
|
-
{'console_scripts': ['APPLICATION-NAME = entry:main']}
|
|
12
|
-
|
|
13
|
-
setup_kwargs = {
|
|
14
|
-
'name': 'stateman',
|
|
15
|
-
'version': '1.2.0',
|
|
16
|
-
'description': 'Creating binary patch files containing changes in the directory',
|
|
17
|
-
'long_description': 'Creating binary patch files containing changes in the directory\n\n## Simple usage :\n```\nfrom stateman import GetState, GetDiff, CreatePatch, ApplyPatch\nimport os\nimport shutil\npatch_file="new.patch"\n\ndef write_file(filename, text):\n with open(filename,\'w\') as f:\n f.write(text)\n\n\n# Clear workspace\nif os.path.exists(".tmp"):\n shutil.rmtree(".tmp")\nif os.path.exists(".tmp2"):\n shutil.rmtree(".tmp2")\n\n\n# Init workspace .tmp with one subfolder\nroot_dir = os.path.abspath(".tmp")\ntarget_dir = os.path.abspath(".tmp2")\nos.makedirs(".tmp")\nsubfolder = os.path.join(root_dir,".git")\nos.makedirs(subfolder)\n\n# Create files in workspace\nwrite_file(os.path.join(subfolder,"testfile1.txt"), "test1")\nwrite_file(os.path.join(subfolder,"testfile2.txt"), "test2")\nwrite_file(os.path.join(root_dir,"testfile3.txt"), "test3")\nwrite_file(os.path.join(root_dir,"testfile4.txt"), "test4")\n\n# Get state of workspace\nstate1 = GetState(root_dir, exclude = r\'.git\'+os.path.sep)\nprint(state1)\n\n# Make a copy of workspace\nshutil.copytree(root_dir, target_dir)\n\n# Make a changes\nos.remove(os.path.join(subfolder,"testfile2.txt"))\nos.remove(os.path.join(root_dir,"testfile4.txt"))\nwrite_file(os.path.join(subfolder,"testfile2.txt"), "changed")\n\n# Get state of workspace after changes\nstate2 = GetState(root_dir, exclude = r\'.git\'+os.path.sep)\nprint(state2)\n\n# Get diff between spaces\ndiff = GetDiff(state1, state2)\nprint (diff)\n\n# Create patch file\nCreatePatch(root_dir, patch_file, diff) \n\n# Apply patch on copy of workspace, maked before changes\nApplyPatch(target_dir, patch_file, exclude = r\'.git\'+os.path.sep)\n\n# Copy of workspace after apply patch identical as workspace \n```\n\n## Source Code:\n* [https://github.com/vpuhoff/stateman](https://github.com/vpuhoff/stateman)\n\n## Travis CI Deploys:\n* [https://travis-ci.com/vpuhoff/patchgen](https://travis-ci.com/vpuhoff/stateman) [](https://travis-ci.com/vpuhoff/stateman)',
|
|
18
|
-
'author': 'pukhov-vi',
|
|
19
|
-
'author_email': 'vipukhov@sberbank.ru',
|
|
20
|
-
'maintainer': None,
|
|
21
|
-
'maintainer_email': None,
|
|
22
|
-
'url': None,
|
|
23
|
-
'packages': packages,
|
|
24
|
-
'package_data': package_data,
|
|
25
|
-
'entry_points': entry_points,
|
|
26
|
-
'python_requires': '>=3.6,<4.0',
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
setup(**setup_kwargs)
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
from collections import OrderedDict
|
|
2
|
-
from zipfile import ZipFile
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
import hashlib
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
sep = os.path.sep
|
|
8
|
-
|
|
9
|
-
def get_hash(filename):
|
|
10
|
-
hash_md5 = hashlib.md5()
|
|
11
|
-
with open(filename, "rb") as f:
|
|
12
|
-
for chunk in iter(lambda: f.read(4096), b""):
|
|
13
|
-
hash_md5.update(chunk)
|
|
14
|
-
return hash_md5.hexdigest()
|
|
15
|
-
|
|
16
|
-
def find_files(folder, exclude=None):
|
|
17
|
-
if folder[-1]!=os.path.sep:
|
|
18
|
-
folder+=os.path.sep
|
|
19
|
-
for root, dirs, files in os.walk(folder):
|
|
20
|
-
for file in files:
|
|
21
|
-
filename = os.path.join(root, file)
|
|
22
|
-
if exclude:
|
|
23
|
-
if not exclude in filename:
|
|
24
|
-
yield filename.replace(folder,'').replace("\\",'/'),get_hash(filename)
|
|
25
|
-
else:
|
|
26
|
-
yield filename.replace(folder,'').replace("\\",'/'),get_hash(filename)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def GetState(folder,exclude=None):
|
|
30
|
-
return dict(find_files(folder, exclude))
|
|
31
|
-
|
|
32
|
-
def GetStateHash(state):
|
|
33
|
-
hashid = hashlib.md5()
|
|
34
|
-
ordered = OrderedDict(sorted(state.items()))
|
|
35
|
-
for k, v in ordered.items():
|
|
36
|
-
hashid.update(k.encode())
|
|
37
|
-
hashid.update(v.encode())
|
|
38
|
-
return hashid.hexdigest()
|
|
39
|
-
|
|
40
|
-
def GetDiff(state1,state2):
|
|
41
|
-
keysA = set(state1.keys())
|
|
42
|
-
keysB = set(state2.keys())
|
|
43
|
-
removed = keysA - keysB
|
|
44
|
-
added = keysB - keysA
|
|
45
|
-
keep = keysA.intersection(keysB)
|
|
46
|
-
changed = [key for key in keep if state1[key]!=state2[key]]
|
|
47
|
-
result = {}
|
|
48
|
-
result['removed'] = list(removed)
|
|
49
|
-
result['added'] = list(added)
|
|
50
|
-
result['changed'] = list(changed)
|
|
51
|
-
result['state'] = state2
|
|
52
|
-
result['md5'] = {}
|
|
53
|
-
result['source_state'] = GetStateHash(state1)
|
|
54
|
-
result['target_state'] = GetStateHash(state2)
|
|
55
|
-
allfiles = set(changed)
|
|
56
|
-
allfiles.update(added)
|
|
57
|
-
for key in allfiles:
|
|
58
|
-
result['md5'][key]=state2[key]
|
|
59
|
-
return result
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def CreatePatch(source_folder, patch_file, diff):
|
|
63
|
-
with ZipFile(patch_file, "w") as z:
|
|
64
|
-
z.writestr("metadata.json", data=json.dumps(diff, indent=4, ensure_ascii=False))
|
|
65
|
-
for file in diff['added']:
|
|
66
|
-
z.write(os.path.join(source_folder, file),arcname=file)
|
|
67
|
-
for file in diff['changed']:
|
|
68
|
-
z.write(os.path.join(source_folder, file),arcname=file)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def ClearPatch(path):
|
|
72
|
-
if sep == "\\":
|
|
73
|
-
newpath = path.replace("/","\\")
|
|
74
|
-
else:
|
|
75
|
-
newpath = path.replace("\\","/")
|
|
76
|
-
return newpath
|
|
77
|
-
|
|
78
|
-
def CheckGitRepo(target):
|
|
79
|
-
from subprocess import Popen, PIPE
|
|
80
|
-
process= Popen("git fsck", cwd=target, stdout=PIPE, stderr=PIPE)
|
|
81
|
-
data = process.communicate()
|
|
82
|
-
code = process.wait()
|
|
83
|
-
if code!= 0:
|
|
84
|
-
raise Exception(data[0].decode()+"\n"+data[1].decode())
|
|
85
|
-
return True
|
|
86
|
-
|
|
87
|
-
def ApplyPatch(target, patch_file, exclude=None):
|
|
88
|
-
with ZipFile(patch_file, "r") as patch:
|
|
89
|
-
with patch.open('metadata.json','r') as metadata:
|
|
90
|
-
diff = json.loads(metadata.read())
|
|
91
|
-
print("Removed: ", len(diff['removed']))
|
|
92
|
-
print("Added: ", len(diff['added']))
|
|
93
|
-
print("Changed: ", len(diff['changed']))
|
|
94
|
-
current_state = GetState(target, exclude)
|
|
95
|
-
state_hash = GetStateHash(current_state)
|
|
96
|
-
print(f"Current state: {state_hash}")
|
|
97
|
-
print(f"Source state: {diff['source_state']}")
|
|
98
|
-
print(f"Target state: {diff['target_state']}")
|
|
99
|
-
if diff['target_state'] ==state_hash:
|
|
100
|
-
#Ничего делать не нужно
|
|
101
|
-
print("The target state corresponds to the patch")
|
|
102
|
-
return True
|
|
103
|
-
else:
|
|
104
|
-
if diff['source_state'] != state_hash:
|
|
105
|
-
print(diff)
|
|
106
|
-
raise Exception("The current state does not match the patch")
|
|
107
|
-
else:
|
|
108
|
-
for filename in diff['removed']:
|
|
109
|
-
path = ClearPatch(os.path.join(target, filename))
|
|
110
|
-
if os.path.isfile(path):
|
|
111
|
-
os.remove(path)
|
|
112
|
-
print("-: ", path)
|
|
113
|
-
for filename in diff['added']:
|
|
114
|
-
path = ClearPatch(os.path.join(target, filename))
|
|
115
|
-
Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True)
|
|
116
|
-
patch.extract(filename, target)
|
|
117
|
-
assert get_hash(path)==diff['md5'][filename]
|
|
118
|
-
print("+: ", path)
|
|
119
|
-
for filename in diff['changed']:
|
|
120
|
-
path = ClearPatch(os.path.join(target, filename))
|
|
121
|
-
if os.path.isfile(path):
|
|
122
|
-
os.remove(path)
|
|
123
|
-
else:
|
|
124
|
-
Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True)
|
|
125
|
-
patch.extract(filename, target)
|
|
126
|
-
assert get_hash(path)==diff['md5'][filename]
|
|
127
|
-
print("*: ", path)
|
|
128
|
-
return True
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
File without changes
|