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.
Files changed (29) hide show
  1. {claudesync-0.3.0/src/claudesync.egg-info → claudesync-0.3.2}/PKG-INFO +1 -1
  2. {claudesync-0.3.0 → claudesync-0.3.2}/pyproject.toml +1 -1
  3. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/api.py +13 -0
  4. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/sync.py +18 -5
  5. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/config_manager.py +7 -1
  6. claudesync-0.3.2/src/claudesync/utils.py +138 -0
  7. {claudesync-0.3.0 → claudesync-0.3.2/src/claudesync.egg-info}/PKG-INFO +1 -1
  8. {claudesync-0.3.0 → claudesync-0.3.2}/tests/test_utils.py +4 -2
  9. claudesync-0.3.0/src/claudesync/utils.py +0 -122
  10. {claudesync-0.3.0 → claudesync-0.3.2}/LICENSE +0 -0
  11. {claudesync-0.3.0 → claudesync-0.3.2}/README.md +0 -0
  12. {claudesync-0.3.0 → claudesync-0.3.2}/setup.cfg +0 -0
  13. {claudesync-0.3.0 → claudesync-0.3.2}/setup.py +0 -0
  14. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/__init__.py +0 -0
  15. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/__init__.py +0 -0
  16. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/main.py +0 -0
  17. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/organization.py +0 -0
  18. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/cli/project.py +0 -0
  19. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/exceptions.py +0 -0
  20. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/provider_factory.py +0 -0
  21. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/providers/__init__.py +0 -0
  22. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync/providers/claude_ai.py +0 -0
  23. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/SOURCES.txt +0 -0
  24. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/dependency_links.txt +0 -0
  25. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/entry_points.txt +0 -0
  26. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/requires.txt +0 -0
  27. {claudesync-0.3.0 → claudesync-0.3.2}/src/claudesync.egg-info/top_level.txt +0 -0
  28. {claudesync-0.3.0 → claudesync-0.3.2}/tests/test_config_manager.py +0 -0
  29. {claudesync-0.3.0 → claudesync-0.3.2}/tests/test_provider_factory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: claudesync
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A tool to synchronize local files with Claude.ai projects
5
5
  Author-email: Jahziah Wagner <jahziah.wagner+pypi@gmail.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claudesync"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  authors = [
9
9
  {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"},
10
10
  ]
@@ -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
- for rf in remote_files:
66
- if rf["file_name"] == local_file:
67
- provider.delete_file(
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 {"log_level": "INFO", "upload_delay": 0.5} # Default values
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.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: claudesync
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A tool to synchronize local files with Claude.ai projects
5
5
  Author-email: Jahziah Wagner <jahziah.wagner+pypi@gmail.com>
6
6
  License: MIT License
@@ -40,8 +40,10 @@ class TestUtils(unittest.TestCase):
40
40
  f.write("Content of file3")
41
41
 
42
42
  # Create a .git file
43
- with open(os.path.join(tmpdir, ".git"), "w") as f:
44
- f.write("*.log\n")
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