jvserve 2.0.3__tar.gz → 2.0.4__tar.gz

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
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: jvserve
3
- Version: 2.0.3
3
+ Version: 2.0.4
4
4
  Summary: FastAPI webserver for loading and interaction with JIVAS agents.
5
5
  Home-page: https://github.com/TrueSelph/jvserve
6
6
  Author: TrueSelph Inc.
@@ -14,6 +14,7 @@ Requires-Dist: pyaml>=25.1.0
14
14
  Requires-Dist: requests>=2.32.3
15
15
  Requires-Dist: aiohttp>=3.10.10
16
16
  Requires-Dist: schedule>=1.2.2
17
+ Requires-Dist: boto3>=1.37.10
17
18
  Provides-Extra: dev
18
19
  Requires-Dist: pre-commit; extra == "dev"
19
20
  Requires-Dist: pytest; extra == "dev"
@@ -26,6 +27,7 @@ Dynamic: description
26
27
  Dynamic: description-content-type
27
28
  Dynamic: home-page
28
29
  Dynamic: keywords
30
+ Dynamic: license-file
29
31
  Dynamic: provides-extra
30
32
  Dynamic: requires-dist
31
33
  Dynamic: requires-python
@@ -4,5 +4,5 @@ jvserve package initialization.
4
4
  This package provides the webserver for loading and interacting with JIVAS agents.
5
5
  """
6
6
 
7
- __version__ = "2.0.3"
7
+ __version__ = "2.0.4"
8
8
  __supported__jivas__versions__ = ["2.0.0"]
@@ -116,6 +116,7 @@ class JacCmd:
116
116
  """Launch the file server."""
117
117
  # load FastAPI
118
118
  from fastapi import FastAPI
119
+ from fastapi.middleware.cors import CORSMiddleware
119
120
  from fastapi.staticfiles import StaticFiles
120
121
 
121
122
  if directory:
@@ -126,6 +127,24 @@ class JacCmd:
126
127
 
127
128
  # Setup custom routes
128
129
  app = FastAPI()
130
+
131
+ # Add CORS middleware
132
+ app.add_middleware(
133
+ CORSMiddleware,
134
+ allow_origins=["*"],
135
+ allow_credentials=True,
136
+ allow_methods=["*"],
137
+ allow_headers=["*"],
138
+ )
139
+
140
+ app.mount(
141
+ "/files",
142
+ StaticFiles(
143
+ directory=os.environ.get("JIVAS_FILES_ROOT_PATH", ".files")
144
+ ),
145
+ name="files",
146
+ )
147
+
129
148
  app.mount(
130
149
  "/files",
131
150
  StaticFiles(
@@ -0,0 +1,170 @@
1
+ """
2
+ File interface module with base class for making file handling configurable
3
+ for different storage backends.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ from abc import ABC, abstractmethod
9
+
10
+ # Interface type determined by environment variable, defaults to local
11
+ FILE_INTERFACE = os.environ.get("JIVAS_FILE_INTERFACE", "local")
12
+
13
+
14
+ class FileInterface(ABC):
15
+ """Abstract base class defining the interface for file storage operations."""
16
+
17
+ __root_dir: str = ""
18
+ LOGGER: logging.Logger = logging.getLogger(__name__)
19
+
20
+ @abstractmethod
21
+ def get_file(self, filename: str) -> bytes | None:
22
+ """Retrieve a file from storage and return its contents as bytes."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ def save_file(self, filename: str, content: bytes) -> bool:
27
+ """Save content to a file in storage."""
28
+ pass
29
+
30
+ @abstractmethod
31
+ def delete_file(self, filename: str) -> bool:
32
+ """Delete a file from storage."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def get_file_url(self, filename: str) -> str | None:
37
+ """Get a URL to access the file."""
38
+ pass
39
+
40
+
41
+ class LocalFileInterface(FileInterface):
42
+ """Implementation of FileInterface for local filesystem storage."""
43
+
44
+ def __init__(self, files_root: str = "") -> None:
45
+ """Initialize local file interface with root directory."""
46
+ self.__root_dir = files_root
47
+
48
+ def get_file(self, filename: str) -> bytes | None:
49
+ """Read and return contents of local file."""
50
+ file_path = os.path.join(self.__root_dir, filename)
51
+ if os.path.exists(file_path):
52
+ with open(file_path, "rb") as f:
53
+ return f.read()
54
+ return None
55
+
56
+ def save_file(self, filename: str, content: bytes) -> bool:
57
+ """Write content to a local file."""
58
+ file_path = os.path.join(self.__root_dir, filename)
59
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
60
+ with open(file_path, "wb") as f:
61
+ f.write(content)
62
+ return True
63
+
64
+ def delete_file(self, filename: str) -> bool:
65
+ """Delete local file."""
66
+ file_path = os.path.join(self.__root_dir, filename)
67
+ if os.path.exists(file_path):
68
+ os.remove(file_path)
69
+ return True
70
+ return False
71
+
72
+ def get_file_url(self, filename: str) -> str | None:
73
+ """Get URL for accessing local file via HTTP."""
74
+ file_path = os.path.join(self.__root_dir, filename)
75
+ if os.path.exists(file_path):
76
+ return f"{os.environ.get('JIVAS_FILES_URL', 'http://localhost:9000/files')}/{filename}"
77
+ return None
78
+
79
+
80
+ class S3FileInterface(FileInterface):
81
+ """Implementation of FileInterface for AWS S3 storage."""
82
+
83
+ def __init__(
84
+ self,
85
+ bucket_name: str,
86
+ aws_access_key_id: str,
87
+ aws_secret_access_key: str,
88
+ region_name: str,
89
+ endpoint_url: str | None = None,
90
+ files_root: str = ".files",
91
+ ) -> None:
92
+ """Initialize S3 file interface."""
93
+ import boto3
94
+ from botocore.config import Config
95
+
96
+ self.s3_client = boto3.client(
97
+ "s3",
98
+ aws_access_key_id=aws_access_key_id,
99
+ aws_secret_access_key=aws_secret_access_key,
100
+ region_name=region_name,
101
+ endpoint_url=endpoint_url,
102
+ config=Config(signature_version="v4"),
103
+ )
104
+ self.bucket_name = bucket_name
105
+ self.__root_dir = files_root
106
+
107
+ # Check for missing AWS credentials
108
+ if not aws_access_key_id or not aws_secret_access_key or not region_name:
109
+ FileInterface.LOGGER.warn(
110
+ "Missing AWS credentials - S3 operations may fail"
111
+ )
112
+
113
+ def get_file(self, filename: str) -> bytes | None:
114
+ """Get file contents from S3."""
115
+ try:
116
+ file_key = os.path.join(self.__root_dir, filename)
117
+ response = self.s3_client.get_object(Bucket=self.bucket_name, Key=file_key)
118
+ return response["Body"].read()
119
+ except Exception:
120
+ return None
121
+
122
+ def save_file(self, filename: str, content: bytes) -> bool:
123
+ """Save file to S3 bucket."""
124
+ try:
125
+ file_key = os.path.join(self.__root_dir, filename)
126
+ self.s3_client.put_object(
127
+ Bucket=self.bucket_name, Key=file_key, Body=content
128
+ )
129
+ return True
130
+ except Exception:
131
+ return False
132
+
133
+ def delete_file(self, filename: str) -> bool:
134
+ """Delete file from S3 bucket."""
135
+ try:
136
+ file_key = os.path.join(self.__root_dir, filename)
137
+ self.s3_client.delete_object(Bucket=self.bucket_name, Key=file_key)
138
+ return True
139
+ except Exception:
140
+ return False
141
+
142
+ def get_file_url(self, filename: str) -> str | None:
143
+ """Get pre-signed URL for S3 file access."""
144
+ try:
145
+ file_key = os.path.join(self.__root_dir, filename)
146
+ url = self.s3_client.generate_presigned_url(
147
+ "get_object",
148
+ Params={"Bucket": self.bucket_name, "Key": file_key},
149
+ ExpiresIn=3600,
150
+ )
151
+ return url
152
+ except Exception:
153
+ return None
154
+
155
+
156
+ file_interface: FileInterface
157
+
158
+ if FILE_INTERFACE == "s3":
159
+ file_interface = S3FileInterface(
160
+ bucket_name=os.environ.get("JIVAS_S3_BUCKET_NAME", ""),
161
+ region_name=os.environ.get("JIVAS_S3_REGION_NAME", "us-east-1"),
162
+ aws_access_key_id=os.environ.get("JIVAS_S3_ACCESS_KEY_ID", ""),
163
+ aws_secret_access_key=os.environ.get("JIVAS_S3_SECRET_ACCESS_KEY", ""),
164
+ endpoint_url=os.environ.get("JIVAS_S3_ENDPOINT_URL"),
165
+ files_root=os.environ.get("JIVAS_FILES_ROOT_PATH", ".files"),
166
+ )
167
+ else:
168
+ file_interface = LocalFileInterface(
169
+ files_root=os.environ.get("JIVAS_FILES_ROOT_PATH", ".files")
170
+ )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: jvserve
3
- Version: 2.0.3
3
+ Version: 2.0.4
4
4
  Summary: FastAPI webserver for loading and interaction with JIVAS agents.
5
5
  Home-page: https://github.com/TrueSelph/jvserve
6
6
  Author: TrueSelph Inc.
@@ -14,6 +14,7 @@ Requires-Dist: pyaml>=25.1.0
14
14
  Requires-Dist: requests>=2.32.3
15
15
  Requires-Dist: aiohttp>=3.10.10
16
16
  Requires-Dist: schedule>=1.2.2
17
+ Requires-Dist: boto3>=1.37.10
17
18
  Provides-Extra: dev
18
19
  Requires-Dist: pre-commit; extra == "dev"
19
20
  Requires-Dist: pytest; extra == "dev"
@@ -26,6 +27,7 @@ Dynamic: description
26
27
  Dynamic: description-content-type
27
28
  Dynamic: home-page
28
29
  Dynamic: keywords
30
+ Dynamic: license-file
29
31
  Dynamic: provides-extra
30
32
  Dynamic: requires-dist
31
33
  Dynamic: requires-python
@@ -12,6 +12,8 @@ jvserve.egg-info/top_level.txt
12
12
  jvserve/lib/__init__.py
13
13
  jvserve/lib/agent_interface.py
14
14
  jvserve/lib/agent_pulse.py
15
+ jvserve/lib/file_interface.py
15
16
  jvserve/lib/jvlogger.py
17
+ tests/test_file_interface.py
16
18
  tests/test_jvlogger.py
17
19
  tests/test_jvserve.py
@@ -3,6 +3,7 @@ pyaml>=25.1.0
3
3
  requests>=2.32.3
4
4
  aiohttp>=3.10.10
5
5
  schedule>=1.2.2
6
+ boto3>=1.37.10
6
7
 
7
8
  [dev]
8
9
  pre-commit
@@ -44,6 +44,7 @@ setup(
44
44
  "requests>=2.32.3",
45
45
  "aiohttp>=3.10.10",
46
46
  "schedule>=1.2.2",
47
+ "boto3>=1.37.10",
47
48
  ],
48
49
  extras_require={
49
50
  "dev": [
@@ -0,0 +1,117 @@
1
+ """Tests for FileInterface classes"""
2
+
3
+ import os
4
+ import unittest
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from jvserve.lib.file_interface import (
8
+ FileInterface,
9
+ LocalFileInterface,
10
+ S3FileInterface,
11
+ )
12
+
13
+
14
+ class TestFileInterface(unittest.TestCase):
15
+ """Test cases for FileInterface implementations"""
16
+
17
+ def setUp(self) -> None:
18
+ """Set up test environment"""
19
+ self.test_filename = "test_file.txt"
20
+ self.test_content = b"test content"
21
+ self.test_root = ".test_files"
22
+
23
+ def tearDown(self) -> None:
24
+ """Clean up test environment"""
25
+ if os.path.exists(self.test_root):
26
+ for file in os.listdir(self.test_root):
27
+ os.remove(os.path.join(self.test_root, file))
28
+ os.rmdir(self.test_root)
29
+
30
+ def test_local_file_interface(self) -> None:
31
+ """Test LocalFileInterface implementation"""
32
+ interface = LocalFileInterface(self.test_root)
33
+
34
+ # Test save_file
35
+ self.assertTrue(interface.save_file(self.test_filename, self.test_content))
36
+
37
+ # Test get_file
38
+ self.assertEqual(interface.get_file(self.test_filename), self.test_content)
39
+ self.assertIsNone(interface.get_file("nonexistent.txt"))
40
+
41
+ # Test get_file_url
42
+ expected_url = f"http://localhost:9000/files/{self.test_filename}"
43
+ self.assertEqual(interface.get_file_url(self.test_filename), expected_url)
44
+ self.assertIsNone(interface.get_file_url("nonexistent.txt"))
45
+
46
+ # Test delete_file
47
+ self.assertTrue(interface.delete_file(self.test_filename))
48
+ self.assertFalse(interface.delete_file("nonexistent.txt"))
49
+
50
+ @patch("boto3.client")
51
+ def test_s3_file_interface(self, mock_boto3_client: MagicMock) -> None:
52
+ """Test S3FileInterface implementation"""
53
+ mock_s3 = MagicMock()
54
+ mock_boto3_client.return_value = mock_s3
55
+
56
+ interface = S3FileInterface(
57
+ bucket_name="test-bucket",
58
+ aws_access_key_id="test-key",
59
+ aws_secret_access_key="test-secret", # pragma: allowlist secret
60
+ region_name="test-region",
61
+ )
62
+
63
+ # Test get_file
64
+ mock_s3.get_object.return_value = {
65
+ "Body": MagicMock(read=lambda: self.test_content)
66
+ }
67
+ self.assertEqual(interface.get_file(self.test_filename), self.test_content)
68
+
69
+ mock_s3.get_object.side_effect = Exception()
70
+ self.assertIsNone(interface.get_file(self.test_filename))
71
+
72
+ # Test save_file
73
+ mock_s3.put_object.return_value = True
74
+ self.assertTrue(interface.save_file(self.test_filename, self.test_content))
75
+
76
+ mock_s3.put_object.side_effect = Exception()
77
+ self.assertFalse(interface.save_file(self.test_filename, self.test_content))
78
+
79
+ # Test delete_file
80
+ mock_s3.delete_object.return_value = True
81
+ self.assertTrue(interface.delete_file(self.test_filename))
82
+
83
+ mock_s3.delete_object.side_effect = Exception()
84
+ self.assertFalse(interface.delete_file(self.test_filename))
85
+
86
+ # Test get_file_url
87
+ mock_s3.generate_presigned_url.return_value = "https://test-url.com"
88
+ self.assertEqual(
89
+ interface.get_file_url(self.test_filename), "https://test-url.com"
90
+ )
91
+
92
+ mock_s3.generate_presigned_url.side_effect = Exception()
93
+ self.assertIsNone(interface.get_file_url(self.test_filename))
94
+
95
+ @patch("boto3.client")
96
+ def test_s3_file_interface_missing_credentials(
97
+ self, mock_boto3_client: MagicMock
98
+ ) -> None:
99
+ """Test S3FileInterface with missing credentials"""
100
+ # Mock logger before creating interface
101
+ mock_logger = MagicMock()
102
+ FileInterface.LOGGER = mock_logger
103
+
104
+ S3FileInterface(
105
+ bucket_name="test-bucket",
106
+ aws_access_key_id="",
107
+ aws_secret_access_key="",
108
+ region_name="",
109
+ )
110
+
111
+ mock_logger.warn.assert_called_once_with(
112
+ "Missing AWS credentials - S3 operations may fail"
113
+ )
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes