vnai 0.1.3__py3-none-any.whl → 2.3.7__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,351 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional, Dict, Any
5
+ from datetime import datetime
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def _get_ide_info() -> Dict:
9
+ try:
10
+ from vnai.scope.device import get_current_ide_info
11
+ return get_current_ide_info()
12
+ except Exception as e:
13
+ logger.debug(f"Failed to get IDE info: {e}")
14
+ return {}
15
+
16
+ def _sync_profile_to_api(
17
+ device_info: Dict[str, Any],
18
+ ide_info: Dict[str, Any],
19
+ license_info: Dict[str, Any],
20
+ api_key: Optional[str] = None,
21
+ force: bool = False
22
+ ) -> Dict[str, Any]:
23
+ try:
24
+ import requests
25
+ except ImportError:
26
+ logger.debug("requests library not available, skipping API sync")
27
+ return {
28
+ 'status':'skipped',
29
+ 'reason':'requests_not_available',
30
+ }
31
+ try:
32
+ profile = {
33
+ 'timestamp': datetime.now().isoformat(),
34
+ 'device': {
35
+ 'device_id': device_info.get('machine_id'),
36
+ 'os': device_info.get('os_name'),
37
+ 'os_platform': device_info.get('platform'),
38
+ 'python_version': device_info.get('python_version'),
39
+ 'arch': device_info.get('platform','').split('-')[-1] if device_info.get('platform') else'unknown',
40
+ 'cpu_count': device_info.get('cpu_count'),
41
+ 'memory_gb': device_info.get('memory_gb'),
42
+ 'environment': device_info.get('environment'),
43
+ 'hosting_service': device_info.get('hosting_service'),
44
+ },
45
+ 'ide': {
46
+ 'name': ide_info.get('ide_name'),
47
+ 'detection_method': ide_info.get('detection_method'),
48
+ 'detected_at': ide_info.get('detected_at'),
49
+ 'process_chain_depth': ide_info.get('process_chain_depth'),
50
+ 'frontend': ide_info.get('frontend'),
51
+ },
52
+ 'license': {
53
+ 'is_paid': license_info.get('is_paid'),
54
+ 'status': license_info.get('status'),
55
+ 'tier': license_info.get('tier'),
56
+ } if license_info else None,
57
+ }
58
+ payload = {
59
+ 'profile': profile,
60
+ 'sync_timestamp': datetime.now().isoformat(),
61
+ 'sync_version':'2.0',
62
+ }
63
+ headers = {
64
+ 'Content-Type':'application/json',
65
+ 'User-Agent':'vnstock-analytics/2.0',
66
+ }
67
+ if api_key:
68
+ headers['Authorization'] =f'Bearer {api_key}'
69
+ endpoint ="https://api.vnstocks.com/v1/user/profile/sync"
70
+ response = requests.post(
71
+ endpoint,
72
+ json=payload,
73
+ headers=headers,
74
+ timeout=10
75
+ )
76
+ if response.status_code == 200:
77
+ logger.info("Profile successfully synced to vnstocks.com API")
78
+ return {
79
+ 'status':'success',
80
+ 'synced_at': datetime.now().isoformat(),
81
+ 'api_response': response.json(),
82
+ }
83
+ else:
84
+ logger.debug(f"API sync failed: {response.status_code}")
85
+ return {
86
+ 'status':'failed',
87
+ 'error': response.text,
88
+ 'status_code': response.status_code,
89
+ }
90
+ except requests.exceptions.RequestException as e:
91
+ logger.debug(f"API request failed: {e}")
92
+ return {
93
+ 'status':'failed',
94
+ 'error': str(e),
95
+ }
96
+ except Exception as e:
97
+ logger.error(f"Error syncing profile to API: {e}")
98
+ return {
99
+ 'status':'error',
100
+ 'error': str(e),
101
+ }
102
+
103
+ class APIKeyChecker:
104
+ _instance = None
105
+ _lock = None
106
+
107
+ def __new__(cls):
108
+ import threading
109
+ if cls._lock is None:
110
+ cls._lock = threading.Lock()
111
+ with cls._lock:
112
+ if cls._instance is None:
113
+ cls._instance = super(APIKeyChecker, cls).__new__(cls)
114
+ cls._instance._initialize()
115
+ return cls._instance
116
+
117
+ def _initialize(self) -> None:
118
+ self.checked = False
119
+ self.last_check_time = None
120
+ self.is_paid = None
121
+ self.license_info = None
122
+
123
+ def _get_vnstock_directories(self) -> list[Path]:
124
+ paths = []
125
+ local_path = Path.home() /".vnstock"
126
+ paths.append(local_path)
127
+ colab_drive_path = Path('/content/drive/MyDrive/.vnstock')
128
+ if colab_drive_path.parent.exists():
129
+ paths.append(colab_drive_path)
130
+ try:
131
+ from vnstock.core.config.ggcolab import get_vnstock_directory
132
+ vnstock_dir = get_vnstock_directory()
133
+ if vnstock_dir not in paths:
134
+ paths.append(vnstock_dir)
135
+ except ImportError:
136
+ pass
137
+ return paths
138
+
139
+ def _find_api_key_file(self) -> Optional[Path]:
140
+ for vnstock_dir in self._get_vnstock_directories():
141
+ api_key_path = vnstock_dir /"api_key.json"
142
+ if api_key_path.exists():
143
+ logger.debug(f"Found api_key.json at: {api_key_path}")
144
+ return api_key_path
145
+ logger.debug("api_key.json not found in any vnstock directory")
146
+ return None
147
+
148
+ def _read_api_key(self, api_key_path: Path) -> Optional[str]:
149
+ try:
150
+ with open(api_key_path,'r', encoding='utf-8') as f:
151
+ data = json.load(f)
152
+ api_key = data.get('api_key')
153
+ if api_key and isinstance(api_key, str):
154
+ return api_key.strip()
155
+ else:
156
+ logger.warning(
157
+ f"Invalid api_key format in {api_key_path}"
158
+ )
159
+ return None
160
+ except json.JSONDecodeError as e:
161
+ logger.error(f"Invalid JSON in {api_key_path}: {e}")
162
+ return None
163
+ except Exception as e:
164
+ logger.error(f"Error reading {api_key_path}: {e}")
165
+ return None
166
+
167
+ def check_license_with_vnii(
168
+ self,
169
+ force: bool = False,
170
+ include_ide_info: bool = True
171
+ ) -> Dict[str, Any]:
172
+ if self.checked and not force:
173
+ result = {
174
+ 'is_paid': self.is_paid,
175
+ 'status':'cached',
176
+ 'checked_at': self.last_check_time,
177
+ 'api_key_found': True,
178
+ 'vnii_available': True
179
+ }
180
+ if include_ide_info:
181
+ result['ide_info'] = _get_ide_info()
182
+ return result
183
+ result = {
184
+ 'is_paid': False,
185
+ 'status':'unknown',
186
+ 'checked_at': datetime.now().isoformat(),
187
+ 'api_key_found': False,
188
+ 'vnii_available': False
189
+ }
190
+ if include_ide_info:
191
+ result['ide_info'] = _get_ide_info()
192
+ try:
193
+ from vnii import lc_init
194
+ result['vnii_available'] = True
195
+ except ImportError:
196
+ logger.debug("vnii package not available")
197
+ result['status'] ='vnii_not_installed'
198
+ return result
199
+ api_key_path = self._find_api_key_file()
200
+ if not api_key_path:
201
+ logger.debug("No api_key.json found")
202
+ result['status'] ='no_api_key_file'
203
+ return result
204
+ api_key = self._read_api_key(api_key_path)
205
+ if not api_key:
206
+ logger.warning("Could not read API key from file")
207
+ result['status'] ='invalid_api_key_file'
208
+ return result
209
+ result['api_key_found'] = True
210
+ try:
211
+ from vnii import lc_init
212
+ import os
213
+ original_env = os.environ.get('VNSTOCK_API_KEY')
214
+ os.environ['VNSTOCK_API_KEY'] = api_key
215
+ try:
216
+ license_info = lc_init(debug=False)
217
+ status = license_info.get('status','').lower()
218
+ tier = license_info.get('tier','').lower()
219
+ is_paid = (
220
+ 'recognized and verified' in status or
221
+ tier in ['bronze','silver','golden','gold']
222
+ )
223
+ result['is_paid'] = is_paid
224
+ result['status'] ='verified' if is_paid else'free_user'
225
+ result['license_info'] = license_info
226
+ self.checked = True
227
+ self.last_check_time = result['checked_at']
228
+ self.is_paid = is_paid
229
+ self.license_info = license_info
230
+ logger.info(
231
+ f"License verified via vnii: "
232
+ f"is_paid={is_paid}, tier={tier}"
233
+ )
234
+ finally:
235
+ if original_env is None:
236
+ os.environ.pop('VNSTOCK_API_KEY', None)
237
+ else:
238
+ os.environ['VNSTOCK_API_KEY'] = original_env
239
+ except SystemExit as e:
240
+ error_msg = str(e)
241
+ if'device limit exceeded' in error_msg.lower():
242
+ logger.warning(f"Device limit exceeded but user is paid")
243
+ result['status'] ='device_limit_exceeded'
244
+ result['is_paid'] = True
245
+ result['error'] = error_msg
246
+ self.checked = True
247
+ self.last_check_time = result['checked_at']
248
+ self.is_paid = True
249
+ self.license_info = {'status':'Device limit','tier':'paid'}
250
+ else:
251
+ logger.warning(f"vnii license check failed: {e}")
252
+ result['status'] ='license_check_failed'
253
+ result['error'] = error_msg
254
+ except Exception as e:
255
+ logger.error(f"Error calling vnii lc_init: {e}")
256
+ result['status'] ='error'
257
+ result['error'] = str(e)
258
+ return result
259
+
260
+ def sync_profile_to_api(
261
+ self,
262
+ device_info: Optional[Dict[str, Any]] = None,
263
+ api_key: Optional[str] = None,
264
+ force: bool = False
265
+ ) -> Dict[str, Any]:
266
+ try:
267
+ ide_info = _get_ide_info()
268
+ license_info = {
269
+ 'is_paid': self.is_paid,
270
+ 'status':'verified' if self.is_paid else'free_user',
271
+ 'tier': self.license_info.get('tier') if self.license_info else None,
272
+ } if self.checked else None
273
+ if device_info is None:
274
+ try:
275
+ from vnai.scope.profile import inspector
276
+ device_data = inspector.examine()
277
+ device_info = {
278
+ 'machine_id': device_data.get('machine_id'),
279
+ 'os_name': device_data.get('os_name'),
280
+ 'platform': device_data.get('platform'),
281
+ 'python_version': device_data.get('python_version'),
282
+ 'cpu_count': device_data.get('cpu_count'),
283
+ 'memory_gb': device_data.get('memory_gb'),
284
+ 'environment': device_data.get('environment'),
285
+ 'hosting_service': device_data.get('hosting_service'),
286
+ }
287
+ except Exception as e:
288
+ logger.debug(f"Failed to get device info: {e}")
289
+ device_info = {}
290
+ return _sync_profile_to_api(
291
+ device_info=device_info,
292
+ ide_info=ide_info,
293
+ license_info=license_info,
294
+ api_key=api_key,
295
+ force=force
296
+ )
297
+ except Exception as e:
298
+ logger.error(f"Error in sync_profile_to_api: {e}")
299
+ return {
300
+ 'status':'error',
301
+ 'error': str(e),
302
+ }
303
+
304
+ def is_paid_user(self) -> Optional[bool]:
305
+ if not self.checked:
306
+ result = self.check_license_with_vnii()
307
+ return result.get('is_paid')
308
+ return self.is_paid
309
+
310
+ def get_license_info(self) -> Optional[Dict]:
311
+ return self.license_info
312
+ api_key_checker = APIKeyChecker()
313
+
314
+ def check_license_via_api_key(force: bool = False) -> Dict[str, Any]:
315
+ return api_key_checker.check_license_with_vnii(force=force)
316
+
317
+ def is_paid_user_via_api_key() -> Optional[bool]:
318
+ return api_key_checker.is_paid_user()
319
+
320
+ def check_license_status() -> Optional[bool]:
321
+ try:
322
+ is_paid = api_key_checker.is_paid_user()
323
+ return is_paid
324
+ except ImportError:
325
+ logger.warning("API key checker not available")
326
+ return None
327
+ except Exception as e:
328
+ logger.error(f"Error checking license status: {e}")
329
+ return None
330
+
331
+ def update_license_from_vnii() -> bool:
332
+ try:
333
+ result = api_key_checker.check_license_with_vnii(force=True)
334
+ if result.get('status') in ['verified','cached']:
335
+ is_paid = result.get('is_paid', False)
336
+ logger.info(f"License updated via API key: is_paid={is_paid}")
337
+ return True
338
+ else:
339
+ logger.warning(
340
+ f"Failed to update license: {result.get('status')}"
341
+ )
342
+ return False
343
+ except Exception as e:
344
+ logger.error(f"Error updating license from vnii: {e}")
345
+ return False
346
+
347
+ def sync_user_profile_to_api(
348
+ api_key: Optional[str] = None,
349
+ force: bool = False
350
+ ) -> Dict[str, Any]:
351
+ return api_key_checker.sync_profile_to_api(api_key=api_key, force=force)
vnai/scope/license.py ADDED
@@ -0,0 +1,197 @@
1
+ import json
2
+ import time
3
+ import threading
4
+ import logging
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from typing import Optional, Dict, Union, Any
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class LicenseCache:
11
+ _instance = None
12
+ _lock = threading.Lock()
13
+ CACHE_TTL = 24 * 3600
14
+ GRACE_PERIOD = 48 * 3600
15
+
16
+ def __new__(cls):
17
+ with cls._lock:
18
+ if cls._instance is None:
19
+ cls._instance = super(LicenseCache, cls).__new__(cls)
20
+ cls._instance._initialize()
21
+ return cls._instance
22
+
23
+ def _initialize(self) -> None:
24
+ try:
25
+ from vnstock.core.config.ggcolab import get_vnstock_directory
26
+ cache_dir = get_vnstock_directory() /"id"
27
+ except ImportError:
28
+ cache_dir = Path.home() /".vnstock" /"id"
29
+ self.cache_dir = cache_dir
30
+ self.cache_file = self.cache_dir /"license_cache.json"
31
+ self.cache = None
32
+ self.lock = threading.Lock()
33
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
34
+ self._load_cache()
35
+
36
+ def _load_cache(self) -> None:
37
+ try:
38
+ if self.cache_file.exists():
39
+ with open(self.cache_file,'r') as f:
40
+ self.cache = json.load(f)
41
+ msg =f"Loaded license cache from {self.cache_file}"
42
+ logger.debug(msg)
43
+ except Exception as e:
44
+ logger.warning(f"Failed to load license cache: {e}")
45
+ self.cache = None
46
+
47
+ def _save_cache(self) -> None:
48
+ try:
49
+ with open(self.cache_file,'w') as f:
50
+ json.dump(self.cache, f, indent=2)
51
+ logger.debug(f"Saved license cache to {self.cache_file}")
52
+ except Exception as e:
53
+ logger.error(f"Failed to save license cache: {e}")
54
+
55
+ def get(self, user_id: str | None = None) -> dict | None:
56
+ with self.lock:
57
+ if not self.cache:
58
+ return None
59
+ current_time = time.time()
60
+ cache_time = self.cache.get("checked_at")
61
+ if not cache_time:
62
+ return None
63
+ try:
64
+ cache_time_ts = datetime.fromisoformat(
65
+ cache_time
66
+ ).timestamp()
67
+ except (ValueError, TypeError):
68
+ cache_time_ts = 0
69
+ age = current_time - cache_time_ts
70
+ if age > self.CACHE_TTL:
71
+ logger.info(
72
+ f"License cache expired (age: {age:.0f}s, TTL: "
73
+ f"{self.CACHE_TTL}s)"
74
+ )
75
+ return None
76
+ return self.cache
77
+
78
+ def set(self, is_paid: bool, checked_at: str | None = None) -> bool:
79
+ with self.lock:
80
+ try:
81
+ if checked_at is None:
82
+ checked_at = datetime.now().isoformat()
83
+ cache_time = datetime.fromisoformat(
84
+ checked_at
85
+ ).timestamp()
86
+ cache_ttl_until = cache_time + self.CACHE_TTL
87
+ grace_until = cache_time + self.GRACE_PERIOD
88
+ self.cache = {
89
+ "is_paid": is_paid,
90
+ "checked_at": checked_at,
91
+ "cache_ttl_until": cache_ttl_until,
92
+ "grace_period_until": grace_until,
93
+ "cache_age_seconds": 0
94
+ }
95
+ self._save_cache()
96
+ logger.info(
97
+ f"License cache updated: is_paid={is_paid}, "
98
+ f"TTL_expires={datetime.fromtimestamp(cache_ttl_until)}"
99
+ )
100
+ return True
101
+ except Exception as e:
102
+ logger.error(f"Failed to set license cache: {e}")
103
+ return False
104
+
105
+ def is_valid(self) -> bool:
106
+ with self.lock:
107
+ if not self.cache:
108
+ return False
109
+ current_time = time.time()
110
+ cache_time = self.cache.get("checked_at")
111
+ if not cache_time:
112
+ return False
113
+ try:
114
+ cache_time_ts = datetime.fromisoformat(
115
+ cache_time
116
+ ).timestamp()
117
+ except (ValueError, TypeError):
118
+ return False
119
+ age = current_time - cache_time_ts
120
+ return age <= self.CACHE_TTL
121
+
122
+ def is_in_grace_period(self) -> bool:
123
+ with self.lock:
124
+ if not self.cache:
125
+ return False
126
+ current_time = time.time()
127
+ cache_time = self.cache.get("checked_at")
128
+ if not cache_time:
129
+ return False
130
+ try:
131
+ cache_time_ts = datetime.fromisoformat(
132
+ cache_time
133
+ ).timestamp()
134
+ except (ValueError, TypeError):
135
+ return False
136
+ age = current_time - cache_time_ts
137
+ return (age > self.CACHE_TTL and
138
+ age <= self.CACHE_TTL + self.GRACE_PERIOD)
139
+
140
+ def get_is_paid(self) -> bool | None:
141
+ cache = self.get()
142
+ if cache:
143
+ return cache.get("is_paid", False)
144
+ if self.is_in_grace_period():
145
+ if self.cache:
146
+ logger.info(
147
+ "Using cached license status (grace period active)"
148
+ )
149
+ return self.cache.get("is_paid", False)
150
+ return None
151
+
152
+ def clear(self) -> bool:
153
+ with self.lock:
154
+ try:
155
+ self.cache = None
156
+ if self.cache_file.exists():
157
+ self.cache_file.unlink()
158
+ logger.info("License cache cleared")
159
+ return True
160
+ except Exception as e:
161
+ logger.error(f"Failed to clear license cache: {e}")
162
+ return False
163
+
164
+ def get_cache_info(self) -> dict:
165
+ with self.lock:
166
+ if not self.cache:
167
+ return {"status":"empty"}
168
+ current_time = time.time()
169
+ cache_time_str = self.cache.get("checked_at")
170
+ if not isinstance(cache_time_str, str):
171
+ return {"status":"invalid"}
172
+ try:
173
+ cache_time = datetime.fromisoformat(cache_time_str)
174
+ cache_time_ts = cache_time.timestamp()
175
+ except (ValueError, TypeError):
176
+ return {"status":"invalid"}
177
+ age = current_time - cache_time_ts
178
+ is_valid = age <= self.CACHE_TTL
179
+ in_grace = (age > self.CACHE_TTL and
180
+ age <= self.CACHE_TTL + self.GRACE_PERIOD)
181
+ return {
182
+ "status":"valid" if is_valid else (
183
+ "grace_period" if in_grace else"expired"
184
+ ),
185
+ "is_paid": self.cache.get("is_paid"),
186
+ "checked_at": cache_time_str,
187
+ "age_seconds": int(age),
188
+ "ttl_seconds": self.CACHE_TTL,
189
+ "ttl_remaining_seconds": max(
190
+ 0, self.CACHE_TTL - int(age)
191
+ ),
192
+ "grace_period_seconds": self.GRACE_PERIOD,
193
+ "grace_remaining_seconds": max(
194
+ 0, self.CACHE_TTL + self.GRACE_PERIOD - int(age)
195
+ )
196
+ }
197
+ license_cache = LicenseCache()