nia-sync 0.1.6__py3-none-any.whl → 0.1.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nia-sync
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Keep your local files in sync with Nia Cloud
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: typer>=0.9.0
@@ -0,0 +1,13 @@
1
+ api_client.py,sha256=wg3oRixlyzR_GmJDrmpVemJb0GI1ixd7UceSyHBR52s,2712
2
+ auth.py,sha256=-DeD3azCHlqqI7zySKEhsxeeT8kJYsfzpuqAsGKyA1s,5720
3
+ config.py,sha256=_zeDLBggazFGlR1LUitMNTiMpqHMxZBfcUJ-C6tXgdY,7233
4
+ extractor.py,sha256=KhLTRJQAgbsZ_qqXzBjU5Qh2R83r2vmNMF-bB4Pzbqw,30279
5
+ main.py,sha256=owUa1k_f3BWlARv7erfxhhvl-aIHWbjTPhD0Ln3T9sc,52672
6
+ sync.py,sha256=gHxA0pn2_I7habRwWJ33tSit0aRDxEZHA2Gdyt6Fe20,12859
7
+ ui.py,sha256=yxlRz2VIkiaMTzV95QhsV3Uojt3Ns6_QXLCSlUWJPr0,3456
8
+ watcher.py,sha256=JmsN9uR7Ss1mDC-kApXL6Hg_wQZWTsO7rRIFkQu8GbM,9978
9
+ nia_sync-0.1.8.dist-info/METADATA,sha256=Phtagx-S9wDy-ARK6C3ZkN-NPlgjoSe3V_R5qKk5hMo,246
10
+ nia_sync-0.1.8.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
+ nia_sync-0.1.8.dist-info/entry_points.txt,sha256=Fx8TIOgXqWdZzZEkEateDtcNfgnwuPW4jZTqlEUrHVs,33
12
+ nia_sync-0.1.8.dist-info/top_level.txt,sha256=nHlEpqudkMdi7d4wpt_qUI-vjVay1X5CaB6foOQ85Dg,54
13
+ nia_sync-0.1.8.dist-info/RECORD,,
@@ -1,6 +1,8 @@
1
+ api_client
1
2
  auth
2
3
  config
3
4
  extractor
4
5
  main
5
6
  sync
7
+ ui
6
8
  watcher
sync.py CHANGED
@@ -14,8 +14,8 @@ from pathlib import Path
14
14
  from typing import Any
15
15
  import httpx
16
16
 
17
- from config import API_BASE_URL, get_api_key
18
- from extractor import extract_incremental, detect_source_type
17
+ from config import get_api_base_url, get_api_key
18
+ from extractor import extract_incremental, detect_source_type, TYPE_FOLDER
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -26,10 +26,31 @@ MAX_BATCH_SIZE_BYTES = 4 * 1024 * 1024 # 4MB max payload per batch
26
26
  MAX_RETRIES = 4
27
27
  RETRY_BASE_DELAY = 1.5
28
28
  RETRY_MAX_DELAY = 15.0
29
+ FOLDER_CURSOR_VERSION = 1
29
30
 
30
31
  # Reusable client for connection pooling
31
32
  _http_client: httpx.Client | None = None
32
33
 
34
+ def _normalize_folder_cursor(
35
+ path: str,
36
+ cursor: dict[str, Any] | None,
37
+ ) -> tuple[dict[str, Any], str | None]:
38
+ if not cursor:
39
+ return {}, "missing"
40
+
41
+ cursor_version = cursor.get("cursor_version")
42
+ root_path = cursor.get("root_path")
43
+ normalized_root = os.path.abspath(os.path.expanduser(root_path)) if root_path else None
44
+
45
+ if cursor_version != FOLDER_CURSOR_VERSION:
46
+ return {}, "version_mismatch"
47
+ if not normalized_root:
48
+ return {}, "missing_root_path"
49
+ if normalized_root != path:
50
+ return {}, "root_path_changed"
51
+
52
+ return cursor, None
53
+
33
54
  def get_http_client() -> httpx.Client:
34
55
  """Get or create HTTP client with connection pooling."""
35
56
  global _http_client
@@ -88,8 +109,9 @@ def sync_source(source: dict[str, Any]) -> dict[str, Any]:
88
109
  "message": "No local path configured",
89
110
  }
90
111
 
91
- # Expand ~ in path
112
+ # Expand ~ and normalize path
92
113
  path = os.path.expanduser(path)
114
+ path = os.path.abspath(path)
93
115
 
94
116
  # Validate path exists
95
117
  if not os.path.exists(path):
@@ -105,6 +127,12 @@ def sync_source(source: dict[str, Any]) -> dict[str, Any]:
105
127
  if not detected_type:
106
128
  detected_type = detect_source_type(path)
107
129
 
130
+ cursor_reset_reason = None
131
+ if detected_type == TYPE_FOLDER:
132
+ cursor, cursor_reset_reason = _normalize_folder_cursor(path, cursor)
133
+ if cursor_reset_reason:
134
+ logger.info(f"Resetting folder sync cursor for {path} ({cursor_reset_reason})")
135
+
108
136
  logger.info(f"Syncing {path} (type={detected_type})")
109
137
 
110
138
  try:
@@ -116,10 +144,38 @@ def sync_source(source: dict[str, Any]) -> dict[str, Any]:
116
144
  )
117
145
 
118
146
  files = extraction_result.get("files", [])
119
- new_cursor = extraction_result.get("cursor", {})
147
+ new_cursor = dict(extraction_result.get("cursor", {}))
120
148
  stats = extraction_result.get("stats", {})
121
149
 
150
+ if detected_type == TYPE_FOLDER:
151
+ new_cursor["cursor_version"] = FOLDER_CURSOR_VERSION
152
+ new_cursor["root_path"] = path
153
+
122
154
  if not files:
155
+ if detected_type == TYPE_FOLDER and cursor_reset_reason:
156
+ cursor_update = new_cursor
157
+ upload_result = upload_sync_data(
158
+ local_folder_id=local_folder_id,
159
+ files=[],
160
+ cursor=cursor_update,
161
+ stats=stats,
162
+ is_final_batch=True,
163
+ )
164
+ if upload_result.get("status") == "ok":
165
+ source["cursor"] = cursor_update
166
+ return {
167
+ "path": path,
168
+ "status": "success",
169
+ "added": 0,
170
+ "message": "No new data (cursor updated)",
171
+ }
172
+ report_sync_error(local_folder_id, upload_result.get("message", "Upload failed"), path)
173
+ return {
174
+ "path": path,
175
+ "status": "error",
176
+ "error": upload_result.get("message", "Upload failed"),
177
+ }
178
+
123
179
  logger.info(f"No new data to sync for {path}")
124
180
  return {
125
181
  "path": path,
@@ -199,7 +255,7 @@ def upload_sync_data(
199
255
  client = get_http_client()
200
256
  response = _post_with_retries(
201
257
  client=client,
202
- url=f"{API_BASE_URL}/v2/daemon/sync",
258
+ url=f"{get_api_base_url()}/v2/daemon/sync",
203
259
  headers={"Authorization": f"Bearer {api_key}"},
204
260
  payload={
205
261
  "local_folder_id": local_folder_id,
@@ -278,7 +334,7 @@ def report_sync_error(local_folder_id: str | None, error: str, path: str | None
278
334
  client = get_http_client()
279
335
  _post_with_retries(
280
336
  client=client,
281
- url=f"{API_BASE_URL}/v2/daemon/sources/{local_folder_id}/error",
337
+ url=f"{get_api_base_url()}/v2/daemon/sources/{local_folder_id}/error",
282
338
  headers={"Authorization": f"Bearer {api_key}"},
283
339
  payload={"error": error, "path": path},
284
340
  )
ui.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ UI utilities for Nia CLI - ASCII art and spinners.
3
+ """
4
+ from rich.console import Console
5
+ from rich.text import Text
6
+ from rich.panel import Panel
7
+ from contextlib import contextmanager
8
+ import random
9
+
10
+ console = Console()
11
+
12
+ NIA_LOGO_MINI = "[bold cyan]NIA[/bold cyan]"
13
+
14
+ # Spinner styles that look good
15
+ SPINNER_STYLES = [
16
+ "dots",
17
+ "dots2",
18
+ "dots3",
19
+ "dots12",
20
+ "line",
21
+ "arc",
22
+ "bouncingBar",
23
+ "bouncingBall",
24
+ "moon",
25
+ "runner",
26
+ "aesthetic",
27
+ ]
28
+
29
+ # Action-specific spinners and messages
30
+ SPINNER_MESSAGES = {
31
+ "login": ["Authenticating...", "Connecting to Nia...", "Verifying..."],
32
+ "sync": ["Syncing...", "Uploading changes...", "Processing files..."],
33
+ "search": ["Searching...", "Finding matches...", "Querying knowledge..."],
34
+ "upgrade": ["Checking for updates...", "Upgrading...", "Installing..."],
35
+ "fetch": ["Fetching...", "Loading data...", "Retrieving..."],
36
+ "connect": ["Connecting...", "Establishing connection...", "Reaching server..."],
37
+ "process": ["Processing...", "Working...", "Computing..."],
38
+ "default": ["Working...", "Please wait...", "Processing..."],
39
+ }
40
+
41
+
42
+ def print_logo(subtitle: str | None = None, compact: bool = False):
43
+ """Print the Nia header (no logo)."""
44
+ pass
45
+
46
+
47
+ def print_header(title: str, subtitle: str | None = None):
48
+ """Print a styled header with optional subtitle."""
49
+ console.print(f"\n{NIA_LOGO_MINI} [bold cyan]{title}[/bold cyan]")
50
+ if subtitle:
51
+ console.print(f" [dim]{subtitle}[/dim]")
52
+ console.print()
53
+
54
+
55
+ @contextmanager
56
+ def spinner(action: str = "default", message: str | None = None, min_time: float = 0.4):
57
+ """
58
+ Context manager for showing a spinner during operations.
59
+
60
+ Usage:
61
+ with spinner("sync", "Syncing files..."):
62
+ do_sync()
63
+ """
64
+ import time
65
+ from rich.status import Status
66
+
67
+ # Pick message
68
+ if message is None:
69
+ messages = SPINNER_MESSAGES.get(action, SPINNER_MESSAGES["default"])
70
+ message = messages[0]
71
+
72
+ # Pick a good spinner
73
+ spinner_name = "dots"
74
+ start_time = time.time()
75
+
76
+ with Status(f"[bold cyan]{message}[/bold cyan]", spinner=spinner_name, console=console) as status:
77
+ # Expose update method through a simple interface
78
+ status._message = message
79
+ yield status
80
+
81
+ # Ensure spinner is visible for at least min_time
82
+ elapsed = time.time() - start_time
83
+ if elapsed < min_time:
84
+ time.sleep(min_time - elapsed)
85
+
86
+
87
+ def update_spinner(status, message: str):
88
+ """Update the spinner message."""
89
+ status.update(f"[bold cyan]{message}[/bold cyan]")
90
+
91
+
92
+ def success(message: str, prefix: str = "✓"):
93
+ """Print a success message."""
94
+ console.print(f"[green]{prefix}[/green] {message}")
95
+
96
+
97
+ def error(message: str, prefix: str = "✗"):
98
+ """Print an error message."""
99
+ console.print(f"[red]{prefix}[/red] {message}")
100
+
101
+
102
+ def warning(message: str, prefix: str = "⚠"):
103
+ """Print a warning message."""
104
+ console.print(f"[yellow]{prefix}[/yellow] {message}")
105
+
106
+
107
+ def info(message: str, prefix: str = "•"):
108
+ """Print an info message."""
109
+ console.print(f"[cyan]{prefix}[/cyan] {message}")
110
+
111
+
112
+ def dim(message: str):
113
+ """Print a dimmed message."""
114
+ console.print(f"[dim]{message}[/dim]")
115
+
116
+
117
+ def print_welcome():
118
+ """Print the welcome message."""
119
+ console.print("[bold cyan]Nia Sync Engine[/bold cyan] — Keep local folders in sync with Nia cloud\n")
@@ -1,11 +0,0 @@
1
- auth.py,sha256=n0ezRqIbz3kZYcyIjH47_MDKgwNgAaVr0Y2NEfRxa50,5704
2
- config.py,sha256=dbnkDihOiJL0-WBzTyxDXM2AIduwsvZycgQuhAoYglU,7637
3
- extractor.py,sha256=IxpFGOlklusmPs2Jz398DH1hVVYpciwQN_HDyxpmm_Q,29193
4
- main.py,sha256=f1e2q99QfI_U0YyYgsC75P1wHebKuweWa0pYEAQPMO8,26582
5
- sync.py,sha256=jdVPceViN5uxoRCr5ukCVumBhvl3t53xkJnvZwvKmdw,10710
6
- watcher.py,sha256=JmsN9uR7Ss1mDC-kApXL6Hg_wQZWTsO7rRIFkQu8GbM,9978
7
- nia_sync-0.1.6.dist-info/METADATA,sha256=BpVvABRhWNRG4dcuFh06zVr7e7Zba2jh95GcR7lZOts,246
8
- nia_sync-0.1.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
- nia_sync-0.1.6.dist-info/entry_points.txt,sha256=Fx8TIOgXqWdZzZEkEateDtcNfgnwuPW4jZTqlEUrHVs,33
10
- nia_sync-0.1.6.dist-info/top_level.txt,sha256=_ZWBugSHWwSpLXYJAcF6TlWmzECu18k0y1-EX27jtBw,40
11
- nia_sync-0.1.6.dist-info/RECORD,,