jac-scale 0.1.1__py3-none-any.whl → 0.1.4__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.
Files changed (36) hide show
  1. jac_scale/abstractions/config/app_config.jac +5 -2
  2. jac_scale/config_loader.jac +2 -1
  3. jac_scale/context.jac +2 -1
  4. jac_scale/factories/storage_factory.jac +75 -0
  5. jac_scale/google_sso_provider.jac +85 -0
  6. jac_scale/impl/config_loader.impl.jac +28 -3
  7. jac_scale/impl/context.impl.jac +1 -0
  8. jac_scale/impl/serve.impl.jac +749 -266
  9. jac_scale/impl/user_manager.impl.jac +349 -0
  10. jac_scale/impl/webhook.impl.jac +212 -0
  11. jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
  12. jac_scale/memory_hierarchy.jac +3 -1
  13. jac_scale/plugin.jac +46 -3
  14. jac_scale/plugin_config.jac +28 -1
  15. jac_scale/serve.jac +33 -16
  16. jac_scale/sso_provider.jac +72 -0
  17. jac_scale/targets/kubernetes/kubernetes_config.jac +9 -15
  18. jac_scale/targets/kubernetes/kubernetes_target.jac +174 -15
  19. jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac +32 -0
  20. jac_scale/tests/fixtures/scale-feats/main.jac +147 -0
  21. jac_scale/tests/fixtures/test_api.jac +89 -0
  22. jac_scale/tests/fixtures/test_restspec.jac +88 -0
  23. jac_scale/tests/test_deploy_k8s.py +2 -1
  24. jac_scale/tests/test_examples.py +180 -5
  25. jac_scale/tests/test_hooks.py +39 -0
  26. jac_scale/tests/test_restspec.py +289 -0
  27. jac_scale/tests/test_serve.py +411 -4
  28. jac_scale/tests/test_sso.py +273 -284
  29. jac_scale/tests/test_storage.py +274 -0
  30. jac_scale/user_manager.jac +49 -0
  31. jac_scale/webhook.jac +93 -0
  32. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/METADATA +11 -4
  33. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/RECORD +36 -23
  34. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +1 -1
  35. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
  36. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,147 @@
1
+ """jac-scale features test fixture.
2
+
3
+ Demonstrates jac-scale specific features that require FastAPI:
4
+ - Storage abstraction with file uploads
5
+ - UploadFile handling
6
+ """
7
+
8
+ import from fastapi { UploadFile }
9
+ import from uuid { uuid4 }
10
+
11
+ # Initialize storage with custom base path
12
+ glob storage = store(base_path="./uploads");
13
+
14
+ # ============================================================================
15
+ # Storage API Walkers
16
+ # ============================================================================
17
+
18
+ """Upload a file to storage."""
19
+ walker:pub upload_file {
20
+ has file: UploadFile;
21
+ has folder: str = "documents";
22
+
23
+ can process with `root entry {
24
+ # Generate unique filename
25
+ ext = self.file.filename.rsplit(".", 1)[-1] if "." in self.file.filename else "";
26
+ unique_name = f"{uuid4()}.{ext}" if ext else str(uuid4());
27
+ path = f"{self.folder}/{unique_name}";
28
+
29
+ # Upload file to storage
30
+ storage.upload(self.file.file, path);
31
+
32
+ # Get metadata
33
+ metadata = storage.get_metadata(path);
34
+
35
+ report {
36
+ "success": True,
37
+ "original_filename": self.file.filename,
38
+ "storage_path": path,
39
+ "size": metadata["size"],
40
+ "content_type": self.file.content_type
41
+ };
42
+ }
43
+ }
44
+
45
+ """List files in storage."""
46
+ walker:pub list_files {
47
+ has folder: str = "";
48
+ has recursive: bool = False;
49
+
50
+ can process with `root entry {
51
+ files = [];
52
+ for path in storage.list_files(self.folder, self.recursive) {
53
+ metadata = storage.get_metadata(path);
54
+ files.append({
55
+ "path": path,
56
+ "size": metadata["size"],
57
+ "modified": str(metadata["modified"])
58
+ });
59
+ }
60
+ report {"files": files, "count": len(files)};
61
+ }
62
+ }
63
+
64
+ """Delete a file from storage."""
65
+ walker:pub delete_file {
66
+ has path: str;
67
+
68
+ can process with `root entry {
69
+ if not storage.exists(self.path) {
70
+ report {"success": False, "error": "File not found"};
71
+ return;
72
+ }
73
+ deleted = storage.delete(self.path);
74
+ report {"success": deleted, "path": self.path};
75
+ }
76
+ }
77
+
78
+ """Download a file from storage."""
79
+ walker:pub download_file {
80
+ has path: str;
81
+
82
+ can process with `root entry {
83
+ if not storage.exists(self.path) {
84
+ report {"success": False, "error": "File not found"};
85
+ return;
86
+ }
87
+ content = storage.download(self.path);
88
+ metadata = storage.get_metadata(self.path);
89
+ report {
90
+ "success": True,
91
+ "path": self.path,
92
+ "size": metadata["size"],
93
+ "content_length": len(content) if content else 0
94
+ };
95
+ }
96
+ }
97
+
98
+ """Check if a file exists in storage."""
99
+ walker:pub file_exists {
100
+ has path: str;
101
+
102
+ can process with `root entry {
103
+ exists = storage.exists(self.path);
104
+ report {"path": self.path, "exists": exists};
105
+ }
106
+ }
107
+
108
+ """Copy a file within storage."""
109
+ walker:pub copy_file {
110
+ has source: str;
111
+ has destination: str;
112
+
113
+ can process with `root entry {
114
+ if not storage.exists(self.source) {
115
+ report {"success": False, "error": "Source file not found"};
116
+ return;
117
+ }
118
+ result = storage.copy(self.source, self.destination);
119
+ report {"success": result, "source": self.source, "destination": self.destination};
120
+ }
121
+ }
122
+
123
+ """Move a file within storage."""
124
+ walker:pub move_file {
125
+ has source: str;
126
+ has destination: str;
127
+
128
+ can process with `root entry {
129
+ if not storage.exists(self.source) {
130
+ report {"success": False, "error": "Source file not found"};
131
+ return;
132
+ }
133
+ result = storage.move(self.source, self.destination);
134
+ report {"success": result, "source": self.source, "destination": self.destination};
135
+ }
136
+ }
137
+
138
+ # ============================================================================
139
+ # Health Check
140
+ # ============================================================================
141
+
142
+ """Simple health check walker."""
143
+ walker:pub health {
144
+ can check with `root entry {
145
+ report {"status": "ok", "features": ["storage", "file_upload"]};
146
+ }
147
+ }
@@ -89,6 +89,35 @@ walker : pub PublicInfo {
89
89
  }
90
90
  }
91
91
 
92
+ # Streaming Walker Test
93
+ walker : pub WalkerStream {
94
+ has count: int = 3;
95
+
96
+ can stream_reports with `root entry {
97
+ def stream -> str {
98
+ import time;
99
+ for i in range(self.count) {
100
+ time.sleep(0.5);
101
+ yield f"Report {i}";
102
+ }
103
+ }
104
+ report stream();
105
+ }
106
+ }
107
+
108
+ import from typing { Generator }
109
+ # Streaming function test
110
+ def : pub FunctionStream(count: int = 3) -> Generator {
111
+ import time;
112
+ def stream -> Generator {
113
+ for i in range(count) {
114
+ time.sleep(0.5);
115
+ yield f"Func {i}";
116
+ }
117
+ }
118
+ report stream();
119
+ }
120
+
92
121
  # Async Walker Test
93
122
  import asyncio;
94
123
 
@@ -157,3 +186,63 @@ walker : pub PublicFileUpload {
157
186
  };
158
187
  }
159
188
  }
189
+
190
+ # ============================================================================
191
+ # Webhook Walkers
192
+ # ============================================================================
193
+ """Test 1: Webhook walker with multiple fields using @restspec(webhook=True).
194
+ This walker should be accessible ONLY via /webhook/PaymentReceived endpoint."""
195
+ @restspec(webhook=True)
196
+ walker PaymentReceived {
197
+ has payment_id: str,
198
+ order_id: str,
199
+ amount: float,
200
+ currency: str = 'USD';
201
+
202
+ can process with `root entry {
203
+ response = {
204
+ "status": "success",
205
+ "message": f"Payment {self.payment_id} processed successfully.",
206
+ "payment_id": self.payment_id,
207
+ "order_id": self.order_id,
208
+ "amount": self.amount,
209
+ "currency": self.currency
210
+ };
211
+ report response;
212
+ }
213
+ }
214
+
215
+ """Test 2: Normal walker without @restspec(webhook=True) (defaults to HTTP).
216
+ This walker should be accessible via /walker/NormalPayment endpoint, NOT /webhook/."""
217
+ walker NormalPayment {
218
+ has payment_id: str,
219
+ order_id: str,
220
+ amount: float,
221
+ currency: str = 'USD';
222
+
223
+ can process with `root entry {
224
+ response = {
225
+ "status": "success",
226
+ "message": f"Normal payment {self.payment_id} processed.",
227
+ "payment_id": self.payment_id,
228
+ "order_id": self.order_id,
229
+ "amount": self.amount,
230
+ "currency": self.currency,
231
+ "transport": "http"
232
+ };
233
+ report response;
234
+ }
235
+ }
236
+
237
+ """Test 3: Minimal webhook walker with ONLY @restspec(webhook=True).
238
+ This walker should be accessible ONLY via /webhook/MinimalWebhook endpoint."""
239
+ @restspec(webhook=True)
240
+ walker MinimalWebhook {
241
+ can process with `root entry {
242
+ report {
243
+ "status": "received",
244
+ "message": "Minimal webhook executed successfully",
245
+ "transport": "webhook"
246
+ };
247
+ }
248
+ }
@@ -0,0 +1,88 @@
1
+
2
+ """Test API fixture for restspec decorator tests."""
3
+
4
+ import from jaclang.runtimelib.builtin { restspec }
5
+ import from http { HTTPMethod }
6
+
7
+ # ============================================================================
8
+ # RestSpec Decorator Tests
9
+ # ============================================================================
10
+
11
+ """Walker with GET method via restspec decorator."""
12
+ @restspec(method=HTTPMethod.GET)
13
+ walker : pub GetWalker {
14
+ can get_data with `root entry {
15
+ report {"message": "GetWalker executed", "method": "GET"};
16
+ }
17
+ }
18
+
19
+ """Walker with custom path."""
20
+ @restspec(method=HTTPMethod.GET, path="/custom/walker")
21
+ walker : pub CustomPathWalker {
22
+ can get_data with `root entry {
23
+ report {"message": "CustomPathWalker executed", "path": "/custom/walker"};
24
+ }
25
+ }
26
+
27
+ """Function with GET method via restspec decorator."""
28
+ @restspec(method=HTTPMethod.GET)
29
+ def get_func() -> dict {
30
+ return {"message": "get_func executed", "method": "GET"};
31
+ }
32
+
33
+ """Function with custom path."""
34
+ @restspec(method=HTTPMethod.GET, path="/custom/func")
35
+ def custom_path_func() -> dict {
36
+ return {"message": "custom_path_func executed", "path": "/custom/func"};
37
+ }
38
+
39
+ """Walker with POST method via restspec decorator."""
40
+ @restspec(method=HTTPMethod.POST)
41
+ walker : pub PostWalker {
42
+ can post_data with `root entry {
43
+ report {"message": "PostWalker executed", "method": "POST"};
44
+ }
45
+ }
46
+
47
+ """Walker with default method (POST)."""
48
+ walker : pub DefaultWalker {
49
+ can post_data with `root entry {
50
+ report {"message": "DefaultWalker executed", "method": "DEFAULT"};
51
+ }
52
+ }
53
+
54
+ """Function with POST method via restspec decorator."""
55
+ @restspec(method=HTTPMethod.POST)
56
+ def post_func() -> dict {
57
+ return {"message": "post_func executed", "method": "POST"};
58
+ }
59
+
60
+ """Function with default method (POST)."""
61
+ def default_func() -> dict {
62
+ return {"message": "default_func executed", "method": "DEFAULT"};
63
+ }
64
+
65
+ """Walker with GET method and parameters."""
66
+ @restspec(method=HTTPMethod.GET)
67
+ walker : pub GetWalkerWithParams {
68
+ has name: str;
69
+ has age: int;
70
+
71
+ can get_data with `root entry {
72
+ report {
73
+ "message": "GetWalkerWithParams executed",
74
+ "name": self.name,
75
+ "age": self.age
76
+ };
77
+ }
78
+ }
79
+
80
+ """Function with GET method and parameters."""
81
+ @restspec(method=HTTPMethod.GET)
82
+ def get_func_with_params(name: str, age: int) -> dict {
83
+ return {
84
+ "message": "get_func_with_params executed",
85
+ "name": name,
86
+ "age": age
87
+ };
88
+ }
@@ -97,10 +97,12 @@ def test_deploy_all_in_one():
97
97
  )
98
98
 
99
99
  # Create app config
100
+ # Use experimental=True to install from repo (PyPI packages may not be available)
100
101
  app_config = AppConfig(
101
102
  code_folder=todo_app_path,
102
103
  file_name="main.jac",
103
104
  build=False,
105
+ experimental=True,
104
106
  )
105
107
 
106
108
  # Deploy using new architecture
@@ -172,7 +174,6 @@ def test_deploy_all_in_one():
172
174
 
173
175
  # Cleanup using new architecture
174
176
  deployment_target.destroy(app_name)
175
- time.sleep(60) # Wait for deletion to propagate
176
177
 
177
178
  # Verify cleanup - resources should no longer exist
178
179
  try:
@@ -19,6 +19,8 @@ JacClientExamples = (
19
19
  / "examples"
20
20
  )
21
21
 
22
+ JacScaleFixtures = Path(__file__).parent / "fixtures"
23
+
22
24
 
23
25
  def get_free_port() -> int:
24
26
  """Get a free port by binding to port 0 and releasing it."""
@@ -184,6 +186,7 @@ class JacScaleTestRunner:
184
186
  "node_modules",
185
187
  ".jac",
186
188
  "package-lock.json",
189
+ "uploads", # Storage API test uploads
187
190
  ]
188
191
  for dir_name in dirs_to_clean:
189
192
  dir_path = example_dir / dir_name
@@ -371,9 +374,7 @@ class JacScaleTestRunner:
371
374
  return response.text
372
375
  return f"Request failed after {max_retries} retries (all timeouts)"
373
376
 
374
- def spawn_walker(
375
- self, walker_name: str, **kwargs: dict[str, Any]
376
- ) -> dict[str, Any]:
377
+ def spawn_walker(self, walker_name: str, **kwargs: object) -> dict[str, Any]:
377
378
  """Spawn a walker with the given parameters.
378
379
 
379
380
  Args:
@@ -387,9 +388,73 @@ class JacScaleTestRunner:
387
388
  "POST", f"/walker/{walker_name}", data=kwargs, use_token=True
388
389
  )
389
390
 
390
- def call_function(
391
- self, function_name: str, **kwargs: dict[str, Any]
391
+ def upload_file(
392
+ self,
393
+ walker_name: str,
394
+ file_field: str,
395
+ filename: str,
396
+ content: bytes,
397
+ content_type: str = "text/plain",
398
+ extra_data: dict[str, Any] | None = None,
399
+ timeout: int = 10,
392
400
  ) -> dict[str, Any]:
401
+ """Upload a file to a walker endpoint using multipart/form-data.
402
+
403
+ Args:
404
+ walker_name: Name of the walker to call
405
+ file_field: Name of the file field in the walker
406
+ filename: Name of the file being uploaded
407
+ content: File content as bytes
408
+ content_type: MIME type of the file
409
+ extra_data: Additional form data fields
410
+ timeout: Request timeout in seconds
411
+
412
+ Returns:
413
+ Walker execution response
414
+ """
415
+ import io
416
+
417
+ url = f"{self.base_url}/walker/{walker_name}"
418
+ headers = {}
419
+
420
+ if self.token:
421
+ headers["Authorization"] = f"Bearer {self.token}"
422
+
423
+ files = {file_field: (filename, io.BytesIO(content), content_type)}
424
+ data = extra_data or {}
425
+
426
+ response = requests.post(
427
+ url, headers=headers, files=files, data=data, timeout=timeout
428
+ )
429
+
430
+ json_response: Any = response.json()
431
+
432
+ # Handle jac-scale's tuple response format [status, body]
433
+ if isinstance(json_response, list) and len(json_response) == 2:
434
+ json_response = json_response[1]
435
+
436
+ # Handle TransportResponse envelope format
437
+ if (
438
+ isinstance(json_response, dict)
439
+ and "ok" in json_response
440
+ and "data" in json_response
441
+ ):
442
+ if json_response.get("ok") and json_response.get("data") is not None:
443
+ return json_response["data"]
444
+ elif not json_response.get("ok") and json_response.get("error"):
445
+ error_info = json_response["error"]
446
+ result: dict[str, Any] = {
447
+ "error": error_info.get("message", "Unknown error")
448
+ }
449
+ if "code" in error_info:
450
+ result["error_code"] = error_info["code"]
451
+ if "details" in error_info:
452
+ result["error_details"] = error_info["details"]
453
+ return result
454
+
455
+ return json_response # type: ignore[return-value]
456
+
457
+ def call_function(self, function_name: str, **kwargs: object) -> dict[str, Any]:
393
458
  """Call a function with the given parameters.
394
459
 
395
460
  Args:
@@ -482,3 +547,113 @@ class TestJacClientExamples:
482
547
  ) as runner:
483
548
  assert "/static/client.js" in runner.request_raw("GET", "/cl/app")
484
549
  assert "import styled from" in runner.request_raw("GET", "/styled.js")
550
+
551
+
552
+ class TestJacScaleFeatures:
553
+ """Test jac-scale specific features using dedicated fixtures."""
554
+
555
+ def test_storage_api(self) -> None:
556
+ """Test Storage API with file upload, list, download, copy, move, delete."""
557
+ example_file = JacScaleFixtures / "scale-feats" / "main.jac"
558
+ with JacScaleTestRunner(
559
+ example_file, session_name="storage_api_test", setup_npm=False
560
+ ) as runner:
561
+ # Health check
562
+ health = runner.spawn_walker("health")
563
+ assert health.get("reports", [[]])[0].get("status") == "ok"
564
+
565
+ # Register user for auth
566
+ runner.create_user("storage_user", "testpass123")
567
+
568
+ # Test file upload
569
+ test_content = b"Test content for storage API"
570
+ upload_result = runner.upload_file(
571
+ walker_name="upload_file",
572
+ file_field="file",
573
+ filename="test_file.txt",
574
+ content=test_content,
575
+ content_type="text/plain",
576
+ extra_data={"folder": "test_docs"},
577
+ )
578
+ reports = upload_result.get("reports", [])
579
+ assert len(reports) > 0, "Upload should return reports"
580
+ file_info = reports[0]
581
+ assert file_info["success"] is True
582
+ assert file_info["original_filename"] == "test_file.txt"
583
+ assert "storage_path" in file_info
584
+ uploaded_path = file_info["storage_path"]
585
+ assert uploaded_path.startswith("test_docs/")
586
+ assert file_info["size"] == len(test_content)
587
+
588
+ # Test file exists
589
+ exists_result = runner.spawn_walker("file_exists", path=uploaded_path)
590
+ reports = exists_result.get("reports", [])
591
+ assert len(reports) > 0
592
+ assert reports[0]["exists"] is True
593
+
594
+ # Test list files
595
+ list_result = runner.spawn_walker("list_files", folder="", recursive=True)
596
+ reports = list_result.get("reports", [])
597
+ assert len(reports) > 0
598
+ list_info = reports[0]
599
+ assert "files" in list_info
600
+ assert list_info["count"] >= 1
601
+ file_paths = [f["path"] for f in list_info["files"]]
602
+ assert uploaded_path in file_paths
603
+
604
+ # Test download file
605
+ download_result = runner.spawn_walker("download_file", path=uploaded_path)
606
+ reports = download_result.get("reports", [])
607
+ assert len(reports) > 0
608
+ assert reports[0]["success"] is True
609
+ assert reports[0]["content_length"] == len(test_content)
610
+
611
+ # Test copy file
612
+ copy_dest = "test_docs/copied_file.txt"
613
+ copy_result = runner.spawn_walker(
614
+ "copy_file", source=uploaded_path, destination=copy_dest
615
+ )
616
+ reports = copy_result.get("reports", [])
617
+ assert len(reports) > 0
618
+ assert reports[0]["success"] is True
619
+
620
+ # Verify copy exists
621
+ exists_result = runner.spawn_walker("file_exists", path=copy_dest)
622
+ assert exists_result.get("reports", [[]])[0]["exists"] is True
623
+
624
+ # Test move file
625
+ move_dest = "test_docs/moved_file.txt"
626
+ move_result = runner.spawn_walker(
627
+ "move_file", source=copy_dest, destination=move_dest
628
+ )
629
+ reports = move_result.get("reports", [])
630
+ assert len(reports) > 0
631
+ assert reports[0]["success"] is True
632
+
633
+ # Verify move: source gone, dest exists
634
+ exists_result = runner.spawn_walker("file_exists", path=copy_dest)
635
+ assert exists_result.get("reports", [[]])[0]["exists"] is False
636
+ exists_result = runner.spawn_walker("file_exists", path=move_dest)
637
+ assert exists_result.get("reports", [[]])[0]["exists"] is True
638
+
639
+ # Test delete file
640
+ delete_result = runner.spawn_walker("delete_file", path=uploaded_path)
641
+ reports = delete_result.get("reports", [])
642
+ assert len(reports) > 0
643
+ assert reports[0]["success"] is True
644
+
645
+ # Verify deleted
646
+ exists_result = runner.spawn_walker("file_exists", path=uploaded_path)
647
+ assert exists_result.get("reports", [[]])[0]["exists"] is False
648
+
649
+ # Test delete nonexistent file
650
+ delete_result = runner.spawn_walker(
651
+ "delete_file", path="nonexistent/file.txt"
652
+ )
653
+ reports = delete_result.get("reports", [])
654
+ assert len(reports) > 0
655
+ assert reports[0]["success"] is False
656
+ assert reports[0]["error"] == "File not found"
657
+
658
+ # Cleanup: delete moved file
659
+ runner.spawn_walker("delete_file", path=move_dest)
@@ -0,0 +1,39 @@
1
+ """Test hook registration for JacScale."""
2
+
3
+ from jac_scale.plugin import JacScalePlugin
4
+ from jac_scale.user_manager import JacScaleUserManager
5
+ from jaclang.pycore.runtime import plugin_manager as pm
6
+
7
+
8
+ def test_get_user_manager_implementation():
9
+ """Test that the plugin method returns the correct class instance."""
10
+ # It returns an Instance, as seen in plugin.jac implementation
11
+ user_manager = JacScalePlugin.get_user_manager(base_path="")
12
+ assert isinstance(user_manager, JacScaleUserManager)
13
+
14
+
15
+ def test_hook_registration():
16
+ """Test that the hook is registered with Jac plugin manager."""
17
+ # Create plugin instance
18
+ plugin = JacScalePlugin()
19
+
20
+ # Register manually for the test
21
+ if not pm.is_registered(plugin):
22
+ pm.register(plugin)
23
+
24
+ # Check hook implementations
25
+ hook_impls = pm.hook.get_user_manager.get_hookimpls()
26
+
27
+ # Verify our plugin's method is in the implementations
28
+ found = False
29
+ for impl in hook_impls:
30
+ # Check if the function belongs to our plugin class or module
31
+ if (
32
+ impl.plugin_name == "scale"
33
+ or isinstance(impl.plugin, JacScalePlugin)
34
+ or impl.function.__qualname__ == "JacScalePlugin.get_user_manager"
35
+ ):
36
+ found = True
37
+ break
38
+
39
+ assert found, "JacScalePlugin.get_user_manager not found in hook implementations"