claudesync 0.3.0__tar.gz → 0.3.2__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.
- {claudesync-0.3.0/src/claudesync.egg-info → claudesync-0.3.2}/PKG-INFO +1 -1
- {claudesync-0.3.0 → claudesync-0.3.2}/pyproject.toml +1 -1
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/api.py +13 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/sync.py +18 -5
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/config_manager.py +7 -1
- claudesync-0.3.2/src/claudesync/utils.py +138 -0
- {claudesync-0.3.0 → claudesync-0.3.2/src/claudesync.egg-info}/PKG-INFO +1 -1
- {claudesync-0.3.0 → claudesync-0.3.2}/tests/test_utils.py +4 -2
- claudesync-0.3.0/src/claudesync/utils.py +0 -122
- {claudesync-0.3.0 → claudesync-0.3.2}/LICENSE +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/README.md +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/setup.cfg +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/setup.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/__init__.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/__init__.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/main.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/organization.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/project.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/exceptions.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/provider_factory.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/providers/__init__.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/providers/claude_ai.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/SOURCES.txt +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/dependency_links.txt +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/entry_points.txt +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/requires.txt +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/top_level.txt +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/tests/test_config_manager.py +0 -0
- {claudesync-0.3.0 → claudesync-0.3.2}/tests/test_provider_factory.py +0 -0
|
@@ -51,3 +51,16 @@ def ratelimit(config, delay):
|
|
|
51
51
|
return
|
|
52
52
|
config.set("upload_delay", delay)
|
|
53
53
|
click.echo(f"Upload delay set to {delay} seconds.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.command()
|
|
57
|
+
@click.option("--size", type=int, required=True, help="Maximum file size in bytes")
|
|
58
|
+
@click.pass_obj
|
|
59
|
+
@handle_errors
|
|
60
|
+
def max_filesize(config, size):
|
|
61
|
+
"""Set the maximum file size for syncing."""
|
|
62
|
+
if size < 0:
|
|
63
|
+
click.echo("Error: Maximum file size must be a non-negative number.")
|
|
64
|
+
return
|
|
65
|
+
config.set("max_file_size", size)
|
|
66
|
+
click.echo(f"Maximum file size set to {size} bytes.")
|
|
@@ -54,6 +54,9 @@ def sync(config):
|
|
|
54
54
|
remote_files = provider.list_files(active_organization_id, active_project_id)
|
|
55
55
|
local_files = get_local_files(local_path)
|
|
56
56
|
|
|
57
|
+
# Track remote files to delete
|
|
58
|
+
remote_files_to_delete = set(rf["file_name"] for rf in remote_files)
|
|
59
|
+
|
|
57
60
|
for local_file, local_checksum in local_files.items():
|
|
58
61
|
remote_file = next(
|
|
59
62
|
(rf for rf in remote_files if rf["file_name"] == local_file), None
|
|
@@ -62,11 +65,9 @@ def sync(config):
|
|
|
62
65
|
remote_checksum = calculate_checksum(remote_file["content"])
|
|
63
66
|
if local_checksum != remote_checksum:
|
|
64
67
|
click.echo(f"Updating {local_file} on remote...")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
active_organization_id, active_project_id, rf["uuid"]
|
|
69
|
-
)
|
|
68
|
+
provider.delete_file(
|
|
69
|
+
active_organization_id, active_project_id, remote_file["uuid"]
|
|
70
|
+
)
|
|
70
71
|
with open(
|
|
71
72
|
os.path.join(local_path, local_file), "r", encoding="utf-8"
|
|
72
73
|
) as file:
|
|
@@ -75,6 +76,7 @@ def sync(config):
|
|
|
75
76
|
active_organization_id, active_project_id, local_file, content
|
|
76
77
|
)
|
|
77
78
|
time.sleep(upload_delay) # Add delay after upload
|
|
79
|
+
remote_files_to_delete.remove(local_file)
|
|
78
80
|
else:
|
|
79
81
|
click.echo(f"Uploading new file {local_file} to remote...")
|
|
80
82
|
with open(
|
|
@@ -86,6 +88,17 @@ def sync(config):
|
|
|
86
88
|
)
|
|
87
89
|
time.sleep(upload_delay) # Add delay after upload
|
|
88
90
|
|
|
91
|
+
# Delete remote files that no longer exist locally
|
|
92
|
+
for file_to_delete in remote_files_to_delete:
|
|
93
|
+
click.echo(f"Deleting {file_to_delete} from remote...")
|
|
94
|
+
remote_file = next(
|
|
95
|
+
rf for rf in remote_files if rf["file_name"] == file_to_delete
|
|
96
|
+
)
|
|
97
|
+
provider.delete_file(
|
|
98
|
+
active_organization_id, active_project_id, remote_file["uuid"]
|
|
99
|
+
)
|
|
100
|
+
time.sleep(upload_delay) # Add delay after deletion
|
|
101
|
+
|
|
89
102
|
click.echo("Sync completed successfully.")
|
|
90
103
|
|
|
91
104
|
|
|
@@ -11,13 +11,19 @@ class ConfigManager:
|
|
|
11
11
|
def _load_config(self):
|
|
12
12
|
if not self.config_file.exists():
|
|
13
13
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
-
return {
|
|
14
|
+
return {
|
|
15
|
+
"log_level": "INFO",
|
|
16
|
+
"upload_delay": 0.5,
|
|
17
|
+
"max_file_size": 32 * 1024, # Default 32 KB
|
|
18
|
+
}
|
|
15
19
|
with open(self.config_file, "r") as f:
|
|
16
20
|
config = json.load(f)
|
|
17
21
|
if "log_level" not in config:
|
|
18
22
|
config["log_level"] = "INFO"
|
|
19
23
|
if "upload_delay" not in config:
|
|
20
24
|
config["upload_delay"] = 0.5
|
|
25
|
+
if "max_file_size" not in config:
|
|
26
|
+
config["max_file_size"] = 32 * 1024 # Default 32 KB
|
|
21
27
|
return config
|
|
22
28
|
|
|
23
29
|
def _save_config(self):
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import pathspec
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from claudesync.exceptions import ConfigurationError, ProviderError
|
|
10
|
+
from claudesync.provider_factory import get_provider
|
|
11
|
+
from claudesync.config_manager import ConfigManager
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
config_manager = ConfigManager()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def calculate_checksum(content):
|
|
19
|
+
normalized_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
20
|
+
return hashlib.md5(normalized_content.encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_gitignore(base_path):
|
|
24
|
+
gitignore_path = os.path.join(base_path, ".gitignore")
|
|
25
|
+
if os.path.exists(gitignore_path):
|
|
26
|
+
with open(gitignore_path, "r") as f:
|
|
27
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", f)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_text_file(file_path, sample_size=8192):
|
|
32
|
+
try:
|
|
33
|
+
with open(file_path, "rb") as file:
|
|
34
|
+
return b"\x00" not in file.read(sample_size)
|
|
35
|
+
except IOError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def calculate_checksum(content):
|
|
40
|
+
return hashlib.md5(content.encode("utf-8")).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_local_files(local_path):
|
|
44
|
+
gitignore = load_gitignore(local_path)
|
|
45
|
+
files = {}
|
|
46
|
+
|
|
47
|
+
# List of directories to exclude
|
|
48
|
+
exclude_dirs = {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS"}
|
|
49
|
+
|
|
50
|
+
for root, dirs, filenames in os.walk(local_path):
|
|
51
|
+
# Remove excluded directories
|
|
52
|
+
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
|
53
|
+
|
|
54
|
+
rel_root = os.path.relpath(root, local_path)
|
|
55
|
+
if rel_root == ".":
|
|
56
|
+
rel_root = ""
|
|
57
|
+
|
|
58
|
+
for filename in filenames:
|
|
59
|
+
rel_path = os.path.join(rel_root, filename)
|
|
60
|
+
full_path = os.path.join(root, filename)
|
|
61
|
+
|
|
62
|
+
# Skip files larger than 200KB
|
|
63
|
+
max_file_size = config_manager.get("max_file_size", 32 * 1024)
|
|
64
|
+
if os.path.getsize(full_path) > max_file_size:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Skip temporary editor files
|
|
68
|
+
if filename.endswith("~"):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Use gitignore rules if available
|
|
72
|
+
if gitignore and gitignore.match_file(rel_path):
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
# Check if it's a text file
|
|
76
|
+
if not is_text_file(full_path):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with open(full_path, "r", encoding="utf-8") as file:
|
|
81
|
+
content = file.read()
|
|
82
|
+
files[rel_path] = calculate_checksum(content)
|
|
83
|
+
except UnicodeDecodeError:
|
|
84
|
+
# If UTF-8 decoding fails, it's likely not a text file we can handle
|
|
85
|
+
logger.debug(f"Unable to read {full_path} as UTF-8 text. Skipping.")
|
|
86
|
+
continue
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"Error reading file {full_path}: {str(e)}")
|
|
89
|
+
|
|
90
|
+
return files
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def handle_errors(func):
|
|
94
|
+
@wraps(func)
|
|
95
|
+
def wrapper(*args, **kwargs):
|
|
96
|
+
try:
|
|
97
|
+
return func(*args, **kwargs)
|
|
98
|
+
except (ConfigurationError, ProviderError) as e:
|
|
99
|
+
click.echo(f"Error: {str(e)}")
|
|
100
|
+
|
|
101
|
+
return wrapper
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_and_get_provider(config, require_org=True):
|
|
105
|
+
active_provider = config.get("active_provider")
|
|
106
|
+
session_key = config.get("session_key")
|
|
107
|
+
if not active_provider or not session_key:
|
|
108
|
+
raise ConfigurationError(
|
|
109
|
+
"No active provider or session key. Please login first."
|
|
110
|
+
)
|
|
111
|
+
if require_org and not config.get("active_organization_id"):
|
|
112
|
+
raise ConfigurationError(
|
|
113
|
+
"No active organization set. Please select an organization."
|
|
114
|
+
)
|
|
115
|
+
return get_provider(active_provider, session_key)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_and_store_local_path(config):
|
|
119
|
+
def get_default_path():
|
|
120
|
+
return os.getcwd()
|
|
121
|
+
|
|
122
|
+
while True:
|
|
123
|
+
default_path = get_default_path()
|
|
124
|
+
local_path = click.prompt(
|
|
125
|
+
"Enter the absolute path to your local project directory",
|
|
126
|
+
type=click.Path(
|
|
127
|
+
exists=True, file_okay=False, dir_okay=True, resolve_path=True
|
|
128
|
+
),
|
|
129
|
+
default=default_path,
|
|
130
|
+
show_default=True,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if os.path.isabs(local_path):
|
|
134
|
+
config.set("local_path", local_path)
|
|
135
|
+
click.echo(f"Local path set to: {local_path}")
|
|
136
|
+
break
|
|
137
|
+
else:
|
|
138
|
+
click.echo("Please enter an absolute path.")
|
|
@@ -40,8 +40,10 @@ class TestUtils(unittest.TestCase):
|
|
|
40
40
|
f.write("Content of file3")
|
|
41
41
|
|
|
42
42
|
# Create a .git file
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
for vcs in {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS"}:
|
|
44
|
+
os.makedirs(os.path.join(tmpdir, vcs), exist_ok=True)
|
|
45
|
+
with open(os.path.join(tmpdir, vcs, ".gitignore"), "w") as f:
|
|
46
|
+
f.write("*.log\n")
|
|
45
47
|
|
|
46
48
|
# Create a test~ file
|
|
47
49
|
with open(os.path.join(tmpdir, "test~"), "w") as f:
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import hashlib
|
|
3
|
-
import mimetypes
|
|
4
|
-
from functools import wraps
|
|
5
|
-
|
|
6
|
-
import click
|
|
7
|
-
import pathspec
|
|
8
|
-
import logging
|
|
9
|
-
|
|
10
|
-
from claudesync.exceptions import ConfigurationError, ProviderError
|
|
11
|
-
from claudesync.provider_factory import get_provider
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def calculate_checksum(content):
|
|
17
|
-
normalized_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
18
|
-
return hashlib.md5(normalized_content.encode("utf-8")).hexdigest()
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def load_gitignore(base_path):
|
|
22
|
-
patterns = []
|
|
23
|
-
current_dir = base_path
|
|
24
|
-
while True:
|
|
25
|
-
gitignore_path = os.path.join(current_dir, ".gitignore")
|
|
26
|
-
if os.path.exists(gitignore_path):
|
|
27
|
-
with open(gitignore_path, "r") as f:
|
|
28
|
-
patterns.extend(f.read().splitlines())
|
|
29
|
-
|
|
30
|
-
if os.path.exists(os.path.join(current_dir, ".git")):
|
|
31
|
-
break # Stop if we've reached the root of the Git repository
|
|
32
|
-
|
|
33
|
-
parent_dir = os.path.dirname(current_dir)
|
|
34
|
-
if parent_dir == current_dir or parent_dir == base_path:
|
|
35
|
-
break # Stop if we've reached the filesystem root or the base watched directory
|
|
36
|
-
current_dir = parent_dir
|
|
37
|
-
|
|
38
|
-
return pathspec.PathSpec.from_lines("gitwildmatch", patterns) if patterns else None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def should_ignore(gitignore, local_path):
|
|
42
|
-
# Check file type
|
|
43
|
-
mime_type, _ = mimetypes.guess_type(local_path)
|
|
44
|
-
if mime_type and not mime_type.startswith("text/"):
|
|
45
|
-
return True
|
|
46
|
-
# Check if .git dir
|
|
47
|
-
if ".git" in local_path.split(os.sep):
|
|
48
|
-
return True
|
|
49
|
-
# Check if temporary editor file
|
|
50
|
-
if local_path.endswith("~"):
|
|
51
|
-
return True
|
|
52
|
-
# Check if too big
|
|
53
|
-
if os.path.getsize(local_path) > 200 * 1024:
|
|
54
|
-
return True
|
|
55
|
-
# Check .gitignore
|
|
56
|
-
return gitignore.match_file(local_path) if gitignore else False
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def get_local_files(local_path):
|
|
60
|
-
gitignore = load_gitignore(local_path)
|
|
61
|
-
files = {}
|
|
62
|
-
for root, _, filenames in os.walk(local_path):
|
|
63
|
-
for filename in filenames:
|
|
64
|
-
file_path = os.path.join(root, filename)
|
|
65
|
-
if not should_ignore(gitignore, file_path):
|
|
66
|
-
rel_path = os.path.relpath(file_path, local_path)
|
|
67
|
-
try:
|
|
68
|
-
with open(file_path, "r", encoding="utf-8") as file:
|
|
69
|
-
content = file.read()
|
|
70
|
-
files[rel_path] = calculate_checksum(content)
|
|
71
|
-
except Exception as e:
|
|
72
|
-
logger.error(f"Error reading file {file_path}: {str(e)}")
|
|
73
|
-
continue
|
|
74
|
-
return files
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def handle_errors(func):
|
|
78
|
-
@wraps(func)
|
|
79
|
-
def wrapper(*args, **kwargs):
|
|
80
|
-
try:
|
|
81
|
-
return func(*args, **kwargs)
|
|
82
|
-
except (ConfigurationError, ProviderError) as e:
|
|
83
|
-
click.echo(f"Error: {str(e)}")
|
|
84
|
-
|
|
85
|
-
return wrapper
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def validate_and_get_provider(config, require_org=True):
|
|
89
|
-
active_provider = config.get("active_provider")
|
|
90
|
-
session_key = config.get("session_key")
|
|
91
|
-
if not active_provider or not session_key:
|
|
92
|
-
raise ConfigurationError(
|
|
93
|
-
"No active provider or session key. Please login first."
|
|
94
|
-
)
|
|
95
|
-
if require_org and not config.get("active_organization_id"):
|
|
96
|
-
raise ConfigurationError(
|
|
97
|
-
"No active organization set. Please select an organization."
|
|
98
|
-
)
|
|
99
|
-
return get_provider(active_provider, session_key)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def validate_and_store_local_path(config):
|
|
103
|
-
def get_default_path():
|
|
104
|
-
return os.getcwd()
|
|
105
|
-
|
|
106
|
-
while True:
|
|
107
|
-
default_path = get_default_path()
|
|
108
|
-
local_path = click.prompt(
|
|
109
|
-
"Enter the absolute path to your local project directory",
|
|
110
|
-
type=click.Path(
|
|
111
|
-
exists=True, file_okay=False, dir_okay=True, resolve_path=True
|
|
112
|
-
),
|
|
113
|
-
default=default_path,
|
|
114
|
-
show_default=True,
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
if os.path.isabs(local_path):
|
|
118
|
-
config.set("local_path", local_path)
|
|
119
|
-
click.echo(f"Local path set to: {local_path}")
|
|
120
|
-
break
|
|
121
|
-
else:
|
|
122
|
-
click.echo("Please enter an absolute path.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|