ctxsync 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ import json
2
+ import os
3
+
4
+ import click
5
+ from ctxsync.exceptions import ProviderError
6
+ from ..utils import (
7
+ handle_errors,
8
+ validate_and_get_provider,
9
+ detect_submodules,
10
+ )
11
+
12
+
13
+ @click.group()
14
+ def submodule():
15
+ """Manage submodules within the current project."""
16
+ pass
17
+
18
+
19
+ @submodule.command()
20
+ @click.pass_obj
21
+ @handle_errors
22
+ def ls(config):
23
+ """List all detected submodules in the current project."""
24
+ local_path = config.get_local_path()
25
+ if not local_path:
26
+ click.echo(
27
+ "No local project path found. Please select an existing project or create a new one using "
28
+ "'ctxsync project select' or 'ctxsync project create'."
29
+ )
30
+ return
31
+
32
+ submodule_detect_filenames = config.get("submodule_detect_filenames", [])
33
+ submodules = detect_submodules(local_path, submodule_detect_filenames)
34
+
35
+ if not submodules:
36
+ click.echo("No submodules detected in the current project.")
37
+ else:
38
+ click.echo("Detected submodules:")
39
+ for submodule, detected_file in submodules:
40
+ click.echo(f" - {submodule} [{detected_file}]")
41
+
42
+
43
+ @submodule.command()
44
+ @click.pass_obj
45
+ @handle_errors
46
+ def create(config):
47
+ """Creates new projects for each detected submodule that doesn't already exist remotely."""
48
+ provider = validate_and_get_provider(config, require_project=True)
49
+ active_organization_id = config.get("active_organization_id")
50
+ active_project_id = config.get("active_project_id")
51
+ active_project_name = config.get("active_project_name")
52
+ local_path = config.get_local_path()
53
+
54
+ if not local_path:
55
+ click.echo(
56
+ "No local project path found. Please select an existing project or create a new one using "
57
+ "'ctxsync project select' or 'ctxsync project create'."
58
+ )
59
+ return
60
+
61
+ submodule_detect_filenames = config.get("submodule_detect_filenames", [])
62
+ submodules_with_files = detect_submodules(local_path, submodule_detect_filenames)
63
+
64
+ if not submodules_with_files:
65
+ click.echo("No submodules detected in the current project.")
66
+ return
67
+
68
+ # Fetch all remote projects
69
+ all_remote_projects = provider.get_projects(
70
+ active_organization_id, include_archived=False
71
+ )
72
+
73
+ click.echo(
74
+ f"Detected {len(submodules_with_files)} submodule(s). Checking for existing remote projects:"
75
+ )
76
+
77
+ # Load existing local config (the project may still use a legacy .claudesync dir)
78
+ local_config_path = os.path.join(local_path, ".ctxsync", "config.local.json")
79
+ if not os.path.exists(local_config_path):
80
+ legacy_path = os.path.join(local_path, ".claudesync", "config.local.json")
81
+ if os.path.exists(legacy_path):
82
+ local_config_path = legacy_path
83
+ with open(local_config_path, "r") as f:
84
+ local_config = json.load(f)
85
+
86
+ # Initialize submodules list if it doesn't exist
87
+ if "submodules" not in local_config:
88
+ local_config["submodules"] = []
89
+
90
+ for i, (submodule, detected_file) in enumerate(submodules_with_files, 1):
91
+ submodule_name = os.path.basename(submodule)
92
+ new_project_name = f"{active_project_name}-SubModule-{submodule_name}"
93
+
94
+ # Check if the submodule project already exists
95
+ existing_project = next(
96
+ (p for p in all_remote_projects if p["name"] == new_project_name), None
97
+ )
98
+
99
+ if existing_project:
100
+ click.echo(
101
+ f"{i}. Submodule '{submodule_name}' already exists as project "
102
+ f"'{new_project_name}' (ID: {existing_project['id']}). Updating local config."
103
+ )
104
+ project_id = existing_project["id"]
105
+ else:
106
+ description = f"Submodule '{submodule_name}' for project '{active_project_name}' (ID: {active_project_id})"
107
+ try:
108
+ new_project = provider.create_project(
109
+ active_organization_id, new_project_name, description
110
+ )
111
+ project_id = new_project["uuid"]
112
+ click.echo(
113
+ f"{i}. Created project '{new_project_name}' (ID: {project_id}) for submodule '{submodule_name}'"
114
+ )
115
+ except ProviderError as e:
116
+ click.echo(
117
+ f"Failed to create project for submodule '{submodule_name}': {str(e)}"
118
+ )
119
+ continue
120
+
121
+ # Update or add submodule information in local config
122
+ submodule_config = {
123
+ "active_provider": config.get("active_provider"),
124
+ "active_organization_id": active_organization_id,
125
+ "active_project_id": project_id,
126
+ "active_project_name": new_project_name,
127
+ "relative_path": submodule,
128
+ }
129
+
130
+ # Check if submodule already exists in config and update it, or append new entry
131
+ submodule_index = next(
132
+ (
133
+ index
134
+ for (index, d) in enumerate(local_config["submodules"])
135
+ if d["relative_path"] == submodule
136
+ ),
137
+ None,
138
+ )
139
+ if submodule_index is not None:
140
+ local_config["submodules"][submodule_index] = submodule_config
141
+ else:
142
+ local_config["submodules"].append(submodule_config)
143
+
144
+ # Save updated local config
145
+ with open(local_config_path, "w") as f:
146
+ json.dump(local_config, f, indent=2)
147
+
148
+ click.echo("\nSubmodule project creation and configuration update completed.")
ctxsync/cli/sync.py ADDED
@@ -0,0 +1,79 @@
1
+ import os
2
+ import shutil
3
+ import sys
4
+ import click
5
+ from crontab import CronTab
6
+
7
+ from ..utils import handle_errors, validate_and_get_provider
8
+
9
+
10
+ @click.command()
11
+ @click.pass_obj
12
+ @handle_errors
13
+ def ls(config):
14
+ """List files in the active remote project."""
15
+ provider = validate_and_get_provider(config, require_project=True)
16
+ active_organization_id = config.get("active_organization_id")
17
+ active_project_id = config.get("active_project_id")
18
+ files = provider.list_files(active_organization_id, active_project_id)
19
+ if not files:
20
+ click.echo("No files found in the active project.")
21
+ else:
22
+ click.echo(
23
+ f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):"
24
+ )
25
+ for file in files:
26
+ click.echo(
27
+ f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})"
28
+ )
29
+
30
+
31
+ def validate_local_path(local_path):
32
+ if not local_path:
33
+ click.echo(
34
+ "No local path set. Please select or create a project to set the local path."
35
+ )
36
+ sys.exit(1)
37
+ if not os.path.exists(local_path):
38
+ click.echo(f"The configured local path does not exist: {local_path}")
39
+ click.echo("Please update the local path by selecting or creating a project.")
40
+ sys.exit(1)
41
+
42
+
43
+ @click.command()
44
+ @click.pass_obj
45
+ @click.option(
46
+ "--interval", type=int, default=5, prompt="Enter sync interval in minutes"
47
+ )
48
+ @handle_errors
49
+ def schedule(config, interval):
50
+ """Set up automated synchronization at regular intervals."""
51
+ ctxsync_path = shutil.which("ctxsync")
52
+ if not ctxsync_path:
53
+ click.echo(
54
+ "Error: ctxsync not found in PATH. Please ensure it's installed correctly."
55
+ )
56
+ sys.exit(1)
57
+
58
+ if sys.platform.startswith("win"):
59
+ setup_windows_task(ctxsync_path, interval)
60
+ else:
61
+ setup_unix_cron(ctxsync_path, interval)
62
+
63
+
64
+ def setup_windows_task(ctxsync_path, interval):
65
+ click.echo("Windows Task Scheduler setup:")
66
+ command = f'schtasks /create /tn "ctxsync" /tr "{ctxsync_path} sync" /sc minute /mo {interval}'
67
+ click.echo(f"Run this command to create the task:\n{command}")
68
+ click.echo('\nTo remove the task, run: schtasks /delete /tn "ctxsync" /f')
69
+
70
+
71
+ def setup_unix_cron(ctxsync_path, interval):
72
+ cron = CronTab(user=True)
73
+ job = cron.new(command=f"{ctxsync_path} sync")
74
+ job.minute.every(interval)
75
+ cron.write()
76
+ click.echo(f"Cron job created successfully! It will run every {interval} minutes.")
77
+ click.echo(
78
+ "\nTo remove the cron job, run: crontab -e and remove the line for ctxsync"
79
+ )
ctxsync/compression.py ADDED
@@ -0,0 +1,302 @@
1
+ import json
2
+ import zlib
3
+ import bz2
4
+ import lzma
5
+ import base64
6
+ import brotli
7
+ from collections import Counter
8
+ import os
9
+ import io
10
+ import heapq
11
+
12
+
13
+ def compress_files(local_path, local_files, algorithm):
14
+ packed_content = _pack_files(local_path, local_files)
15
+ return compress_content(packed_content, algorithm)
16
+
17
+
18
+ def decompress_files(local_path, compressed_content, algorithm):
19
+ decompressed_content = decompress_content(compressed_content, algorithm)
20
+ _unpack_files(local_path, decompressed_content)
21
+
22
+
23
+ def _pack_files(local_path, local_files):
24
+ packed_content = io.StringIO()
25
+ for file_path, file_hash in local_files.items():
26
+ full_path = os.path.join(local_path, file_path)
27
+ with open(full_path, "r", encoding="utf-8") as f:
28
+ content = f.read()
29
+ packed_content.write(f"--- BEGIN FILE: {file_path} ---\n")
30
+ packed_content.write(content)
31
+ packed_content.write(f"\n--- END FILE: {file_path} ---\n")
32
+ return packed_content.getvalue()
33
+
34
+
35
+ def _unpack_files(local_path, decompressed_content):
36
+ current_file = None
37
+ current_content = io.StringIO()
38
+
39
+ for line in decompressed_content.splitlines():
40
+ if line.startswith("--- BEGIN FILE:"):
41
+ if current_file:
42
+ _write_file(local_path, current_file, current_content.getvalue())
43
+ current_content = io.StringIO()
44
+ current_file = line.split("--- BEGIN FILE:")[1].strip()
45
+ elif line.startswith("--- END FILE:"):
46
+ if current_file:
47
+ _write_file(local_path, current_file, current_content.getvalue())
48
+ current_file = None
49
+ current_content = io.StringIO()
50
+ else:
51
+ current_content.write(line + "\n")
52
+
53
+ if current_file:
54
+ _write_file(local_path, current_file, current_content.getvalue())
55
+
56
+
57
+ def _write_file(local_path, file_path, content):
58
+ full_path = os.path.join(local_path, file_path)
59
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
60
+ with open(full_path, "w", encoding="utf-8") as f:
61
+ f.write(content)
62
+
63
+
64
+ def compress_content(content, algorithm):
65
+ compressors = {
66
+ "zlib": zlib_compress,
67
+ "bz2": bz2_compress,
68
+ "lzma": lzma_compress,
69
+ "brotli": brotli_compress, # Add Brotli to compressors
70
+ "dictionary": dictionary_compress,
71
+ "rle": rle_compress,
72
+ "huffman": huffman_compress,
73
+ "lzw": lzw_compress,
74
+ "pack": no_compress,
75
+ }
76
+ if algorithm in compressors:
77
+ return compressors[algorithm](content)
78
+ else:
79
+ return content # No compression
80
+
81
+
82
+ def decompress_content(compressed_content, algorithm):
83
+ decompressors = {
84
+ "zlib": zlib_decompress,
85
+ "bz2": bz2_decompress,
86
+ "lzma": lzma_decompress,
87
+ "brotli": brotli_decompress, # Add Brotli to decompressors
88
+ "dictionary": dictionary_decompress,
89
+ "rle": rle_decompress,
90
+ "huffman": huffman_decompress,
91
+ "lzw": lzw_decompress,
92
+ "pack": no_decompress,
93
+ }
94
+ if algorithm in decompressors:
95
+ return decompressors[algorithm](compressed_content)
96
+ else:
97
+ return compressed_content # No decompression
98
+
99
+
100
+ # Pack compression
101
+ def no_compress(text):
102
+ return text
103
+
104
+
105
+ def no_decompress(compressed_text):
106
+ return compressed_text
107
+
108
+
109
+ # Brotli compression
110
+ def brotli_compress(text):
111
+ compressed = brotli.compress(text.encode("utf-8"))
112
+ return base64.b64encode(compressed).decode("ascii")
113
+
114
+
115
+ def brotli_decompress(compressed_text):
116
+ decoded = base64.b64decode(compressed_text.encode("ascii"))
117
+ return brotli.decompress(decoded).decode("utf-8")
118
+
119
+
120
+ # Zlib compression
121
+ def zlib_compress(text):
122
+ compressed = zlib.compress(text.encode("utf-8"))
123
+ return base64.b64encode(compressed).decode("ascii")
124
+
125
+
126
+ def zlib_decompress(compressed_text):
127
+ decoded = base64.b64decode(compressed_text.encode("ascii"))
128
+ return zlib.decompress(decoded).decode("utf-8")
129
+
130
+
131
+ # BZ2 compression
132
+ def bz2_compress(text):
133
+ compressed = bz2.compress(text.encode("utf-8"))
134
+ return base64.b64encode(compressed).decode("ascii")
135
+
136
+
137
+ def bz2_decompress(compressed_text):
138
+ decoded = base64.b64decode(compressed_text.encode("ascii"))
139
+ return bz2.decompress(decoded).decode("utf-8")
140
+
141
+
142
+ # LZMA compression
143
+ def lzma_compress(text):
144
+ compressed = lzma.compress(text.encode("utf-8"))
145
+ return base64.b64encode(compressed).decode("ascii")
146
+
147
+
148
+ def lzma_decompress(compressed_text):
149
+ decoded = base64.b64decode(compressed_text.encode("ascii"))
150
+ return lzma.decompress(decoded).decode("utf-8")
151
+
152
+
153
+ # Dictionary-based compression
154
+ def dictionary_compress(text):
155
+ words = text.split()
156
+ dictionary = {}
157
+ compressed = []
158
+
159
+ for word in words:
160
+ if word not in dictionary:
161
+ dictionary[word] = str(len(dictionary))
162
+ compressed.append(dictionary[word])
163
+
164
+ return json.dumps({"dict": dictionary, "compressed": " ".join(compressed)})
165
+
166
+
167
+ def dictionary_decompress(compressed_text):
168
+ data = json.loads(compressed_text)
169
+ dictionary = {v: k for k, v in data["dict"].items()}
170
+ return " ".join(dictionary[token] for token in data["compressed"].split())
171
+
172
+
173
+ # Run-length encoding (RLE)
174
+ def rle_compress(text):
175
+ compressed = []
176
+ count = 1
177
+ for i in range(1, len(text)):
178
+ if text[i] == text[i - 1]:
179
+ count += 1
180
+ else:
181
+ compressed.append((text[i - 1], count))
182
+ count = 1
183
+ compressed.append((text[-1], count))
184
+ return json.dumps(compressed)
185
+
186
+
187
+ def rle_decompress(compressed_text):
188
+ compressed = json.loads(compressed_text)
189
+ return "".join(char * count for char, count in compressed)
190
+
191
+
192
+ # Huffman coding
193
+ class HuffmanNode:
194
+ def __init__(self, char, freq):
195
+ self.char = char
196
+ self.freq = freq
197
+ self.left = None
198
+ self.right = None
199
+
200
+ def __lt__(self, other):
201
+ return self.freq < other.freq
202
+
203
+
204
+ def huffman_compress(text):
205
+ freq = Counter(text)
206
+ heap = [HuffmanNode(char, freq) for char, freq in freq.items()]
207
+ heapq.heapify(heap)
208
+
209
+ while len(heap) > 1:
210
+ left = heapq.heappop(heap)
211
+ right = heapq.heappop(heap)
212
+ merged = HuffmanNode(None, left.freq + right.freq)
213
+ merged.left = left
214
+ merged.right = right
215
+ heapq.heappush(heap, merged)
216
+
217
+ root = heap[0]
218
+ codes = {}
219
+
220
+ def generate_codes(node, code):
221
+ if node.char:
222
+ codes[node.char] = code
223
+ return
224
+ generate_codes(node.left, code + "0")
225
+ generate_codes(node.right, code + "1")
226
+
227
+ generate_codes(root, "")
228
+
229
+ encoded = "".join(codes[char] for char in text)
230
+ padding = 8 - len(encoded) % 8
231
+ encoded += "0" * padding
232
+
233
+ compressed = bytearray()
234
+ for i in range(0, len(encoded), 8):
235
+ byte = encoded[i : i + 8]
236
+ compressed.append(int(byte, 2))
237
+
238
+ return json.dumps(
239
+ {
240
+ "tree": {char: code for char, code in codes.items()},
241
+ "padding": padding,
242
+ "data": base64.b64encode(compressed).decode("ascii"),
243
+ }
244
+ )
245
+
246
+
247
+ def huffman_decompress(compressed_text):
248
+ data = json.loads(compressed_text)
249
+ tree = {code: char for char, code in data["tree"].items()}
250
+ padding = data["padding"]
251
+ compressed = base64.b64decode(data["data"].encode("ascii"))
252
+
253
+ binary = "".join(f"{byte:08b}" for byte in compressed)
254
+ binary = binary[:-padding] if padding else binary
255
+
256
+ decoded = ""
257
+ code = ""
258
+ for bit in binary:
259
+ code += bit
260
+ if code in tree:
261
+ decoded += tree[code]
262
+ code = ""
263
+
264
+ return decoded
265
+
266
+
267
+ # LZW compression
268
+ def lzw_compress(text):
269
+ dictionary = {chr(i): i for i in range(256)}
270
+ result = []
271
+ w = ""
272
+ for c in text:
273
+ wc = w + c
274
+ if wc in dictionary:
275
+ w = wc
276
+ else:
277
+ result.append(dictionary[w])
278
+ dictionary[wc] = len(dictionary)
279
+ w = c
280
+ if w:
281
+ result.append(dictionary[w])
282
+ return base64.b64encode(bytes(result)).decode("ascii")
283
+
284
+
285
+ def lzw_decompress(compressed_text):
286
+ compressed = base64.b64decode(compressed_text.encode("ascii"))
287
+ dictionary = {i: chr(i) for i in range(256)}
288
+ result = []
289
+ w = chr(compressed[0])
290
+ result.append(w)
291
+ for i in range(1, len(compressed)):
292
+ k = compressed[i]
293
+ if k in dictionary:
294
+ entry = dictionary[k]
295
+ elif k == len(dictionary):
296
+ entry = w + w[0]
297
+ else:
298
+ raise ValueError("Bad compressed k: %s" % k)
299
+ result.append(entry)
300
+ dictionary[len(dictionary)] = w + entry[0]
301
+ w = entry
302
+ return "".join(result)
@@ -0,0 +1,5 @@
1
+ from .base_config_manager import BaseConfigManager
2
+ from .file_config_manager import FileConfigManager
3
+ from .inmemory_config_manager import InMemoryConfigManager
4
+
5
+ __all__ = ["BaseConfigManager", "FileConfigManager", "InMemoryConfigManager"]