jac-scale 0.1.1__py3-none-any.whl → 0.1.3__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.
@@ -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"
@@ -0,0 +1,192 @@
1
+ """Test for restspec decorator functionality."""
2
+
3
+ import contextlib
4
+ import gc
5
+ import glob
6
+ import socket
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+
15
+ def get_free_port() -> int:
16
+ """Get a free port by binding to port 0 and releasing it."""
17
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18
+ s.bind(("", 0))
19
+ s.listen(1)
20
+ port = s.getsockname()[1]
21
+ return port
22
+
23
+
24
+ class TestRestSpec:
25
+ """Test restspec decorator functionality."""
26
+
27
+ fixtures_dir: Path
28
+ test_file: Path
29
+ port: int
30
+ base_url: str
31
+ server_process: subprocess.Popen[str] | None = None
32
+
33
+ @classmethod
34
+ def setup_class(cls) -> None:
35
+ """Set up test class - runs once for all tests."""
36
+ cls.fixtures_dir = Path(__file__).parent / "fixtures"
37
+ cls.test_file = cls.fixtures_dir / "test_restspec.jac"
38
+
39
+ if not cls.test_file.exists():
40
+ raise FileNotFoundError(f"Test fixture not found: {cls.test_file}")
41
+
42
+ cls.port = get_free_port()
43
+ cls.base_url = f"http://localhost:{cls.port}"
44
+
45
+ cls._cleanup_db_files()
46
+ cls._start_server()
47
+
48
+ @classmethod
49
+ def teardown_class(cls) -> None:
50
+ """Tear down test class - runs once after all tests."""
51
+ if cls.server_process:
52
+ cls.server_process.terminate()
53
+ try:
54
+ cls.server_process.wait(timeout=5)
55
+ except subprocess.TimeoutExpired:
56
+ cls.server_process.kill()
57
+ cls.server_process.wait()
58
+
59
+ time.sleep(0.5)
60
+ gc.collect()
61
+ cls._cleanup_db_files()
62
+
63
+ @classmethod
64
+ def _start_server(cls) -> None:
65
+ """Start the jac-scale server in a subprocess."""
66
+ import sys
67
+
68
+ jac_executable = Path(sys.executable).parent / "jac"
69
+ cmd = [
70
+ str(jac_executable),
71
+ "start",
72
+ cls.test_file.name,
73
+ "--port",
74
+ str(cls.port),
75
+ ]
76
+
77
+ cls.server_process = subprocess.Popen(
78
+ cmd,
79
+ stdout=subprocess.PIPE,
80
+ stderr=subprocess.PIPE,
81
+ text=True,
82
+ cwd=str(cls.fixtures_dir),
83
+ )
84
+
85
+ # Wait for server
86
+ max_attempts = 50
87
+ for _ in range(max_attempts):
88
+ if cls.server_process.poll() is not None:
89
+ stdout, stderr = cls.server_process.communicate()
90
+ raise RuntimeError(f"Server died: {stdout}\n{stderr}")
91
+ try:
92
+ requests.get(f"{cls.base_url}/docs", timeout=1)
93
+ return
94
+ except requests.RequestException:
95
+ time.sleep(0.5)
96
+
97
+ cls.server_process.kill()
98
+ raise RuntimeError("Server failed to start")
99
+
100
+ @classmethod
101
+ def _cleanup_db_files(cls) -> None:
102
+ """Cleanup database files."""
103
+ import shutil
104
+
105
+ for pattern in ["*.db", "*.db-wal", "*.db-shm", "anchor_store*"]:
106
+ for f in glob.glob(pattern):
107
+ with contextlib.suppress(Exception):
108
+ Path(f).unlink()
109
+ for pattern in ["*.db", "*.db-wal", "*.db-shm"]:
110
+ for f in glob.glob(str(cls.fixtures_dir / pattern)):
111
+ with contextlib.suppress(Exception):
112
+ Path(f).unlink()
113
+ client_dir = cls.fixtures_dir / ".jac"
114
+ if client_dir.exists():
115
+ with contextlib.suppress(Exception):
116
+ shutil.rmtree(client_dir)
117
+
118
+ def _extract_data(self, response: dict[str, Any] | list[Any]) -> Any: # noqa: ANN401
119
+ # Handle tuple response [status, body]
120
+ if isinstance(response, list) and len(response) == 2:
121
+ response = response[1]
122
+
123
+ if isinstance(response, dict) and "data" in response:
124
+ return response["data"]
125
+ return response
126
+
127
+ def test_custom_method_walker(self) -> None:
128
+ """Test walker with custom GET method."""
129
+ response = requests.get(f"{self.base_url}/walker/GetWalker", timeout=5)
130
+ assert response.status_code == 200
131
+ data = self._extract_data(response.json())
132
+ assert data["reports"][0]["message"] == "GetWalker executed"
133
+
134
+ def test_custom_path_walker(self) -> None:
135
+ """Test walker with custom path."""
136
+ response = requests.get(f"{self.base_url}/custom/walker", timeout=5)
137
+ assert response.status_code == 200
138
+ data = self._extract_data(response.json())
139
+ assert data["reports"][0]["message"] == "CustomPathWalker executed"
140
+ assert data["reports"][0]["path"] == "/custom/walker"
141
+
142
+ def test_custom_method_func(self) -> None:
143
+ """Test function with custom GET method."""
144
+ requests.post(
145
+ f"{self.base_url}/user/register", json={"username": "u1", "password": "p1"}
146
+ )
147
+ login = requests.post(
148
+ f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
149
+ )
150
+ token = self._extract_data(login.json())["token"]
151
+
152
+ response = requests.get(
153
+ f"{self.base_url}/function/get_func",
154
+ headers={"Authorization": f"Bearer {token}"},
155
+ timeout=5,
156
+ )
157
+ assert response.status_code == 200
158
+ data = self._extract_data(response.json())
159
+ assert data["result"]["message"] == "get_func executed"
160
+
161
+ def test_custom_path_func(self) -> None:
162
+ """Test function with custom path."""
163
+ # Use existing user token if possible, but simplest is fresh login
164
+ login = requests.post(
165
+ f"{self.base_url}/user/login", json={"username": "u1", "password": "p1"}
166
+ )
167
+ token = self._extract_data(login.json())["token"]
168
+
169
+ response = requests.get(
170
+ f"{self.base_url}/custom/func",
171
+ headers={"Authorization": f"Bearer {token}"},
172
+ timeout=5,
173
+ )
174
+ assert response.status_code == 200
175
+ data = self._extract_data(response.json())
176
+ assert data["result"]["message"] == "custom_path_func executed"
177
+ assert data["result"]["path"] == "/custom/func"
178
+
179
+ def test_openapi_specs(self) -> None:
180
+ """Verify OpenAPI documentation reflects custom paths and methods."""
181
+ spec = requests.get(f"{self.base_url}/openapi.json").json()
182
+ paths = spec["paths"]
183
+
184
+ assert "/custom/walker" in paths
185
+ assert "get" in paths["/custom/walker"]
186
+
187
+ assert "/custom/func" in paths
188
+ assert "get" in paths["/custom/func"]
189
+
190
+ assert "/walker/GetWalker" in paths
191
+ assert "get" in paths["/walker/GetWalker"]
192
+ assert "post" not in paths["/walker/GetWalker"]
@@ -1833,3 +1833,57 @@ class TestJacScaleServeDevMode:
1833
1833
  assert reports[1]["status"] == "after_async_wait"
1834
1834
  assert reports[2]["status"] == "completed"
1835
1835
  assert "task" in reports[2]
1836
+
1837
+ def test_walker_stream_response(self) -> None:
1838
+ """Test that walker streaming responses work correctly."""
1839
+ response = requests.post(
1840
+ f"{self.base_url}/walker/WalkerStream",
1841
+ json={"count": 3},
1842
+ timeout=30,
1843
+ stream=True,
1844
+ )
1845
+
1846
+ assert response.status_code == 200, (
1847
+ f"Failed with status {response.status_code}: {response.text}"
1848
+ )
1849
+ assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
1850
+ assert response.headers.get("cache-control") == "no-cache"
1851
+ assert response.headers.get("connection") == "close"
1852
+
1853
+ # Collect streaming content
1854
+ content = ""
1855
+ for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
1856
+ if chunk:
1857
+ content += chunk
1858
+
1859
+ # The generator yields "Report 0", "Report 1", "Report 2" without delimiters
1860
+ # They get concatenated together in the stream
1861
+ expected = "Report 0Report 1Report 2"
1862
+ assert content == expected, f"Expected '{expected}', got '{content}'"
1863
+
1864
+ def test_function_stream_response(self) -> None:
1865
+ """Test that function streaming responses work correctly."""
1866
+ response = requests.post(
1867
+ f"{self.base_url}/function/FunctionStream",
1868
+ json={"count": 2},
1869
+ timeout=30,
1870
+ stream=True,
1871
+ )
1872
+
1873
+ assert response.status_code == 200, (
1874
+ f"Failed with status {response.status_code}: {response.text}"
1875
+ )
1876
+ assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
1877
+ assert response.headers.get("cache-control") == "no-cache"
1878
+ assert response.headers.get("connection") == "close"
1879
+
1880
+ # Collect streaming content
1881
+ content = ""
1882
+ for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
1883
+ if chunk:
1884
+ content += chunk
1885
+
1886
+ # The generator yields "Func 0", "Func 1" without delimiters
1887
+ # They get concatenated together in the stream
1888
+ expected = "Func 0Func 1"
1889
+ assert content == expected, f"Expected '{expected}', got '{content}'"