issues-fs 0.3.0__tar.gz → 0.4.0__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.
- {issues_fs-0.3.0 → issues_fs-0.4.0}/PKG-INFO +2 -1
- issues_fs-0.4.0/issues_fs/issues/Issue__Path__Config.py +25 -0
- issues_fs-0.4.0/issues_fs/issues/__init__.py +3 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/Comments__Service.py +235 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/Graph__Repository.py +307 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/Graph__Repository__Factory.py +106 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/Link__Service.py +227 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/Node__Service.py +392 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/Type__Service.py +209 -0
- issues_fs-0.4.0/issues_fs/issues/graph_services/__init__.py +3 -0
- issues_fs-0.4.0/issues_fs/issues/phase_1/Issue__Children__Service.py +340 -0
- issues_fs-0.4.0/issues_fs/issues/phase_1/Root__Issue__Service.py +137 -0
- issues_fs-0.4.0/issues_fs/issues/phase_1/Root__Selection__Service.py +310 -0
- issues_fs-0.4.0/issues_fs/issues/phase_1/__init__.py +3 -0
- issues_fs-0.4.0/issues_fs/issues/status/Git__Status__Service.py +115 -0
- issues_fs-0.4.0/issues_fs/issues/status/Index__Status__Service.py +131 -0
- issues_fs-0.4.0/issues_fs/issues/status/Server__Status__Service.py +113 -0
- issues_fs-0.4.0/issues_fs/issues/status/Storage__Status__Service.py +75 -0
- issues_fs-0.4.0/issues_fs/issues/status/Types__Status__Service.py +75 -0
- issues_fs-0.4.0/issues_fs/issues/status/__init__.py +3 -0
- issues_fs-0.4.0/issues_fs/issues/storage/Path__Handler__Graph_Node.py +185 -0
- issues_fs-0.4.0/issues_fs/issues/storage/Path__Handler__Issues.py +57 -0
- issues_fs-0.4.0/issues_fs/issues/storage/__init__.py +3 -0
- issues_fs-0.4.0/issues_fs/schemas/enums/Enum__Comment__Author.py +10 -0
- issues_fs-0.4.0/issues_fs/schemas/enums/Enum__Graph__Storage__Backend.py +8 -0
- issues_fs-0.4.0/issues_fs/schemas/enums/Enum__Issue__Status.py +13 -0
- issues_fs-0.4.0/issues_fs/schemas/enums/__init__.py +3 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Safe_Str__Graph_Types.py +63 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Global__Index.py +16 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Graph__Link.py +8 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Graph__Node.py +10 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Graph__Response.py +21 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Link__Create__Request.py +12 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Link__Create__Response.py +15 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Link__Delete__Response.py +16 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Link__List__Response.py +15 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Link__Type.py +20 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node.py +43 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Create__Request.py +19 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Create__Response.py +14 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Delete__Response.py +15 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Link.py +17 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__List__Response.py +16 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Summary.py +15 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Type.py +28 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Update__Request.py +18 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Node__Update__Response.py +14 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Property__Definition.py +27 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Type__Index.py +16 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/Schema__Type__Summary.py +13 -0
- issues_fs-0.4.0/issues_fs/schemas/graph/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/schemas/identifiers/Comment_Id.py +5 -0
- issues_fs-0.4.0/issues_fs/schemas/identifiers/Issue_Id.py +13 -0
- issues_fs-0.4.0/issues_fs/schemas/identifiers/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/schemas/issues/Schema__Comment.py +61 -0
- issues_fs-0.4.0/issues_fs/schemas/issues/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/schemas/issues/phase_1/Schema__Issue__Children.py +85 -0
- issues_fs-0.4.0/issues_fs/schemas/issues/phase_1/Schema__Root.py +60 -0
- issues_fs-0.4.0/issues_fs/schemas/issues/phase_1/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/schemas/safe_str/Safe_Str__Hex_Color.py +10 -0
- issues_fs-0.4.0/issues_fs/schemas/safe_str/Safe_Str__Issue_Id.py +14 -0
- issues_fs-0.4.0/issues_fs/schemas/safe_str/Safe_Str__Issue__Node__Description.py +15 -0
- issues_fs-0.4.0/issues_fs/schemas/safe_str/Safe_Str__Label_Name.py +9 -0
- issues_fs-0.4.0/issues_fs/schemas/safe_str/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/schemas/status/Schema__API__Info.py +15 -0
- issues_fs-0.4.0/issues_fs/schemas/status/Schema__Git__Status.py +21 -0
- issues_fs-0.4.0/issues_fs/schemas/status/Schema__Index__Status.py +22 -0
- issues_fs-0.4.0/issues_fs/schemas/status/Schema__Server__Status.py +28 -0
- issues_fs-0.4.0/issues_fs/schemas/status/Schema__Storage__Status.py +23 -0
- issues_fs-0.4.0/issues_fs/schemas/status/Schema__Types__Status.py +30 -0
- issues_fs-0.4.0/issues_fs/schemas/status/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/utils/__init__.py +0 -0
- issues_fs-0.4.0/issues_fs/version +1 -0
- {issues_fs-0.3.0 → issues_fs-0.4.0}/pyproject.toml +2 -1
- issues_fs-0.3.0/issues_fs/version +0 -1
- {issues_fs-0.3.0 → issues_fs-0.4.0}/LICENSE +0 -0
- {issues_fs-0.3.0 → issues_fs-0.4.0}/README.md +0 -0
- {issues_fs-0.3.0 → issues_fs-0.4.0}/issues_fs/__init__.py +0 -0
- {issues_fs-0.3.0/issues_fs/utils → issues_fs-0.4.0/issues_fs/schemas}/__init__.py +0 -0
- {issues_fs-0.3.0 → issues_fs-0.4.0}/issues_fs/utils/Version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: issues_fs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Issues-FS
|
|
5
5
|
Home-page: https://github.com/owasp-sbot/Issues-FS
|
|
6
6
|
License: Apache 2.0
|
|
@@ -10,6 +10,7 @@ Requires-Python: >=3.12,<4.0
|
|
|
10
10
|
Classifier: License :: Other/Proprietary License
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: memory_fs
|
|
13
14
|
Requires-Dist: osbot-utils
|
|
14
15
|
Project-URL: Repository, https://github.com/owasp-sbot/Issues-FS
|
|
15
16
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# Issue__Path__Config - Configuration for issue tracking paths
|
|
3
|
+
# Provides the default issues directory path based on project structure
|
|
4
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
import issues_fs
|
|
6
|
+
from osbot_utils.type_safe.Type_Safe import Type_Safe
|
|
7
|
+
from osbot_utils.type_safe.primitives.domains.files.safe_str.Safe_Str__File__Path import Safe_Str__File__Path
|
|
8
|
+
from osbot_utils.utils.Files import path_combine, folder_exists, folder_create
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Issue__Path__Config(Type_Safe): # Issue path configuration
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def default_issues_path(cls) -> Safe_Str__File__Path: # Get default issues directory
|
|
15
|
+
root_path = issues_fs.path
|
|
16
|
+
issues_path = path_combine(root_path, '../docs/dev-briefs/issues')
|
|
17
|
+
return issues_path
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def ensure_issues_directory(cls, path: Safe_Str__File__Path = None) -> Safe_Str__File__Path: # Create issues directory if needed
|
|
21
|
+
if path is None:
|
|
22
|
+
path = cls.default_issues_path()
|
|
23
|
+
if folder_exists(path) is False:
|
|
24
|
+
folder_create(path)
|
|
25
|
+
return path
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# Comments__Service - Business logic for comment CRUD operations
|
|
3
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
from osbot_utils.type_safe.Type_Safe import Type_Safe
|
|
7
|
+
from osbot_utils.type_safe.primitives.domains.identifiers.Obj_Id import Obj_Id
|
|
8
|
+
from osbot_utils.type_safe.primitives.domains.identifiers.safe_int.Timestamp_Now import Timestamp_Now
|
|
9
|
+
from issues_fs.schemas.graph.Safe_Str__Graph_Types import Safe_Str__Node_Type, Safe_Str__Node_Label
|
|
10
|
+
from issues_fs.schemas.issues.Schema__Comment import Schema__Comment__List__Response, Schema__Comment__Create__Request, Schema__Comment__Response, Schema__Comment, Schema__Comment__Update__Request, Schema__Comment__Delete__Response
|
|
11
|
+
from issues_fs.issues.graph_services.Graph__Repository import Graph__Repository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Comments__Service(Type_Safe): # Comment business logic service
|
|
15
|
+
repository : Graph__Repository = None # Graph__Repository instance
|
|
16
|
+
|
|
17
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
18
|
+
# List Comments
|
|
19
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
def list_comments(self , # List all comments on a node
|
|
22
|
+
node_type : Safe_Str__Node_Type ,
|
|
23
|
+
label : Safe_Str__Node_Label
|
|
24
|
+
) -> Schema__Comment__List__Response:
|
|
25
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
26
|
+
label = label )
|
|
27
|
+
if node is None:
|
|
28
|
+
return Schema__Comment__List__Response(success = False ,
|
|
29
|
+
comments = [] ,
|
|
30
|
+
total = 0 ,
|
|
31
|
+
message = f'Node not found: {label}')
|
|
32
|
+
|
|
33
|
+
raw_comments = node.properties.get('comments', []) if node.properties else []
|
|
34
|
+
comments = self._parse_comments(raw_comments)
|
|
35
|
+
|
|
36
|
+
return Schema__Comment__List__Response(success = True ,
|
|
37
|
+
comments = comments ,
|
|
38
|
+
total = len(comments) )
|
|
39
|
+
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
41
|
+
# Create Comment
|
|
42
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
def create_comment(self , # Add comment to node
|
|
45
|
+
node_type : Safe_Str__Node_Type ,
|
|
46
|
+
label : Safe_Str__Node_Label ,
|
|
47
|
+
request : Schema__Comment__Create__Request
|
|
48
|
+
) -> Schema__Comment__Response:
|
|
49
|
+
# Validate request
|
|
50
|
+
if not request.text or str(request.text).strip() == '':
|
|
51
|
+
return Schema__Comment__Response(success = False ,
|
|
52
|
+
message = 'Comment text is required')
|
|
53
|
+
|
|
54
|
+
if not request.author or str(request.author).strip() == '':
|
|
55
|
+
return Schema__Comment__Response(success = False ,
|
|
56
|
+
message = 'Author is required' )
|
|
57
|
+
|
|
58
|
+
# Load node
|
|
59
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
60
|
+
label = label )
|
|
61
|
+
if node is None:
|
|
62
|
+
return Schema__Comment__Response(success = False ,
|
|
63
|
+
message = f'Node not found: {label}')
|
|
64
|
+
|
|
65
|
+
# Create comment with server-generated ID and timestamp
|
|
66
|
+
now = Timestamp_Now()
|
|
67
|
+
comment = Schema__Comment(id = Obj_Id() ,
|
|
68
|
+
author = request.author ,
|
|
69
|
+
text = request.text ,
|
|
70
|
+
created_at = now ,
|
|
71
|
+
updated_at = now )
|
|
72
|
+
|
|
73
|
+
# Add to node properties
|
|
74
|
+
if node.properties is None:
|
|
75
|
+
node.properties = {}
|
|
76
|
+
if 'comments' not in node.properties:
|
|
77
|
+
node.properties['comments'] = []
|
|
78
|
+
|
|
79
|
+
node.properties['comments'].append(comment.json())
|
|
80
|
+
node.updated_at = now
|
|
81
|
+
|
|
82
|
+
# Save node
|
|
83
|
+
if self.repository.node_save(node) is False:
|
|
84
|
+
return Schema__Comment__Response(success = False ,
|
|
85
|
+
message = 'Failed to save node' )
|
|
86
|
+
|
|
87
|
+
return Schema__Comment__Response(success = True ,
|
|
88
|
+
comment = comment )
|
|
89
|
+
|
|
90
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
91
|
+
# Get Single Comment
|
|
92
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
93
|
+
|
|
94
|
+
def get_comment(self , # Get single comment by ID
|
|
95
|
+
node_type : Safe_Str__Node_Type ,
|
|
96
|
+
label : Safe_Str__Node_Label ,
|
|
97
|
+
comment_id : str
|
|
98
|
+
) -> Schema__Comment__Response:
|
|
99
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
100
|
+
label = label )
|
|
101
|
+
if node is None:
|
|
102
|
+
return Schema__Comment__Response(success = False ,
|
|
103
|
+
message = f'Node not found: {label}')
|
|
104
|
+
|
|
105
|
+
raw_comments = node.properties.get('comments', []) if node.properties else []
|
|
106
|
+
|
|
107
|
+
for raw in raw_comments:
|
|
108
|
+
if raw.get('id') == comment_id:
|
|
109
|
+
comment = self._parse_comment(raw)
|
|
110
|
+
return Schema__Comment__Response(success = True ,
|
|
111
|
+
comment = comment )
|
|
112
|
+
|
|
113
|
+
return Schema__Comment__Response(success = False ,
|
|
114
|
+
message = f'Comment not found: {comment_id}')
|
|
115
|
+
|
|
116
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
117
|
+
# Update Comment
|
|
118
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
119
|
+
|
|
120
|
+
def update_comment(self , # Edit comment text
|
|
121
|
+
node_type : Safe_Str__Node_Type ,
|
|
122
|
+
label : Safe_Str__Node_Label ,
|
|
123
|
+
comment_id : str ,
|
|
124
|
+
request : Schema__Comment__Update__Request
|
|
125
|
+
) -> Schema__Comment__Response:
|
|
126
|
+
# Validate request
|
|
127
|
+
if not request.text or str(request.text).strip() == '':
|
|
128
|
+
return Schema__Comment__Response(success = False ,
|
|
129
|
+
message = 'Comment text is required')
|
|
130
|
+
|
|
131
|
+
# Load node
|
|
132
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
133
|
+
label = label )
|
|
134
|
+
if node is None:
|
|
135
|
+
return Schema__Comment__Response(success = False ,
|
|
136
|
+
message = f'Node not found: {label}')
|
|
137
|
+
|
|
138
|
+
raw_comments = node.properties.get('comments', []) if node.properties else []
|
|
139
|
+
|
|
140
|
+
# Find and update comment
|
|
141
|
+
found = False
|
|
142
|
+
updated = None
|
|
143
|
+
now = Timestamp_Now()
|
|
144
|
+
|
|
145
|
+
for raw in raw_comments:
|
|
146
|
+
if raw.get('id') == comment_id:
|
|
147
|
+
raw['text'] = str(request.text)
|
|
148
|
+
raw['updated_at'] = int(now)
|
|
149
|
+
updated = self._parse_comment(raw)
|
|
150
|
+
found = True
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if not found:
|
|
154
|
+
return Schema__Comment__Response(success = False ,
|
|
155
|
+
message = f'Comment not found: {comment_id}')
|
|
156
|
+
|
|
157
|
+
# Save node
|
|
158
|
+
node.properties['comments'] = raw_comments
|
|
159
|
+
node.updated_at = now
|
|
160
|
+
|
|
161
|
+
if self.repository.node_save(node) is False:
|
|
162
|
+
return Schema__Comment__Response(success = False ,
|
|
163
|
+
message = 'Failed to save node' )
|
|
164
|
+
|
|
165
|
+
return Schema__Comment__Response(success = True ,
|
|
166
|
+
comment = updated )
|
|
167
|
+
|
|
168
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
169
|
+
# Delete Comment
|
|
170
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
171
|
+
|
|
172
|
+
def delete_comment(self , # Remove comment from node
|
|
173
|
+
node_type : Safe_Str__Node_Type ,
|
|
174
|
+
label : Safe_Str__Node_Label ,
|
|
175
|
+
comment_id : str
|
|
176
|
+
) -> Schema__Comment__Delete__Response:
|
|
177
|
+
# Load node
|
|
178
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
179
|
+
label = label )
|
|
180
|
+
if node is None:
|
|
181
|
+
return Schema__Comment__Delete__Response(success = False ,
|
|
182
|
+
deleted = False ,
|
|
183
|
+
comment_id = comment_id ,
|
|
184
|
+
message = f'Node not found: {label}')
|
|
185
|
+
|
|
186
|
+
raw_comments = node.properties.get('comments', []) if node.properties else []
|
|
187
|
+
original_len = len(raw_comments)
|
|
188
|
+
|
|
189
|
+
# Filter out the comment to delete
|
|
190
|
+
raw_comments = [c for c in raw_comments if c.get('id') != comment_id]
|
|
191
|
+
|
|
192
|
+
if len(raw_comments) == original_len:
|
|
193
|
+
return Schema__Comment__Delete__Response(success = False ,
|
|
194
|
+
deleted = False ,
|
|
195
|
+
comment_id = comment_id ,
|
|
196
|
+
message = f'Comment not found: {comment_id}')
|
|
197
|
+
|
|
198
|
+
# Save node
|
|
199
|
+
node.properties['comments'] = raw_comments
|
|
200
|
+
node.updated_at = Timestamp_Now()
|
|
201
|
+
|
|
202
|
+
if self.repository.node_save(node) is False:
|
|
203
|
+
return Schema__Comment__Delete__Response(success = False ,
|
|
204
|
+
deleted = False ,
|
|
205
|
+
comment_id = comment_id ,
|
|
206
|
+
message = 'Failed to save node' )
|
|
207
|
+
|
|
208
|
+
return Schema__Comment__Delete__Response(success = True ,
|
|
209
|
+
deleted = True ,
|
|
210
|
+
comment_id = comment_id )
|
|
211
|
+
|
|
212
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
213
|
+
# Helper Methods
|
|
214
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
215
|
+
|
|
216
|
+
def _parse_comments(self, raw_comments: list) -> List[Schema__Comment]: # Parse raw dicts to Schema__Comment
|
|
217
|
+
comments = []
|
|
218
|
+
for raw in raw_comments:
|
|
219
|
+
comment = self._parse_comment(raw)
|
|
220
|
+
if comment:
|
|
221
|
+
comments.append(comment)
|
|
222
|
+
return comments
|
|
223
|
+
|
|
224
|
+
def _parse_comment(self, raw: dict) -> Schema__Comment: # Parse single raw dict
|
|
225
|
+
if not raw:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
return Schema__Comment(id = Obj_Id(raw.get('id', str(Obj_Id()))) ,
|
|
230
|
+
author = raw.get('author', 'unknown') ,
|
|
231
|
+
text = raw.get('text', '') ,
|
|
232
|
+
created_at = Timestamp_Now(raw.get('created_at', raw.get('timestamp', 0))) ,
|
|
233
|
+
updated_at = Timestamp_Now(raw.get('updated_at', raw.get('created_at', 0))) )
|
|
234
|
+
except Exception:
|
|
235
|
+
return None
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# Graph__Repository - Memory-FS based data access for graph nodes
|
|
3
|
+
# Storage-agnostic: works with Memory, Local Disk, S3, SQLite, ZIP backends
|
|
4
|
+
#
|
|
5
|
+
# Phase 1 Changes:
|
|
6
|
+
# - node_load: Reads issue.json first, falls back to node.json
|
|
7
|
+
# - node_save: Always writes to issue.json (preserves node.json for now)
|
|
8
|
+
# - node_exists: Checks for either issue.json or node.json
|
|
9
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
from memory_fs.Memory_FS import Memory_FS
|
|
13
|
+
from memory_fs.storage_fs.Storage_FS import Storage_FS
|
|
14
|
+
from osbot_utils.type_safe.Type_Safe import Type_Safe
|
|
15
|
+
from osbot_utils.type_safe.type_safe_core.decorators.type_safe import type_safe
|
|
16
|
+
from osbot_utils.utils.Json import json_loads, json_dumps
|
|
17
|
+
from issues_fs.schemas.graph.Safe_Str__Graph_Types import Safe_Str__Node_Type, Safe_Str__Node_Label
|
|
18
|
+
from issues_fs.schemas.graph.Schema__Global__Index import Schema__Global__Index
|
|
19
|
+
from issues_fs.schemas.graph.Schema__Node import Schema__Node
|
|
20
|
+
from issues_fs.schemas.graph.Schema__Node__Type import Schema__Node__Type
|
|
21
|
+
from issues_fs.schemas.graph.Schema__Link__Type import Schema__Link__Type
|
|
22
|
+
from issues_fs.schemas.graph.Schema__Type__Index import Schema__Type__Index
|
|
23
|
+
from issues_fs.issues.storage.Path__Handler__Graph_Node import Path__Handler__Graph_Node
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Graph__Repository(Type_Safe): # Memory-FS based graph repository
|
|
27
|
+
memory_fs : Memory_FS # Storage abstraction
|
|
28
|
+
path_handler : Path__Handler__Graph_Node # Path generation
|
|
29
|
+
storage_fs : Storage_FS = None # Set from memory_fs
|
|
30
|
+
|
|
31
|
+
def __init__(self, **kwargs):
|
|
32
|
+
super().__init__(**kwargs)
|
|
33
|
+
if self.memory_fs:
|
|
34
|
+
self.storage_fs = self.memory_fs.storage_fs
|
|
35
|
+
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
# Node Operations - Phase 1: Dual File Support
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
@type_safe
|
|
41
|
+
def node_save(self, node: Schema__Node) -> bool: # Save node to issue.json
|
|
42
|
+
if not node.label:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
path_issue = self.path_handler.path_for_issue_json(node_type = node.node_type, # Always write to issue.json
|
|
46
|
+
label = node.label )
|
|
47
|
+
data = node.json()
|
|
48
|
+
content = json_dumps(data, indent=2)
|
|
49
|
+
return self.storage_fs.file__save(path_issue, content.encode('utf-8'))
|
|
50
|
+
|
|
51
|
+
@type_safe
|
|
52
|
+
def node_load(self , # Load node from issue.json or node.json
|
|
53
|
+
node_type : Safe_Str__Node_Type ,
|
|
54
|
+
label : Safe_Str__Node_Label
|
|
55
|
+
) -> Schema__Node:
|
|
56
|
+
path = self.get_issue_file_path(node_type, label) # Get actual file path (issue.json or node.json)
|
|
57
|
+
if path is None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
content = self.storage_fs.file__str(path)
|
|
61
|
+
if not content:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
data = json_loads(content)
|
|
65
|
+
if data is None:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
return Schema__Node.from_json(data)
|
|
69
|
+
|
|
70
|
+
@type_safe
|
|
71
|
+
def node_delete(self , # Delete node from storage
|
|
72
|
+
node_type : Safe_Str__Node_Type ,
|
|
73
|
+
label : Safe_Str__Node_Label
|
|
74
|
+
) -> bool:
|
|
75
|
+
deleted_any = False
|
|
76
|
+
path_issue = self.path_handler.path_for_issue_json(node_type, label) # Delete issue.json if exists
|
|
77
|
+
path_node = self.path_handler.path_for_node_json(node_type, label) # Delete node.json if exists
|
|
78
|
+
|
|
79
|
+
if self.storage_fs.file__exists(path_issue):
|
|
80
|
+
self.storage_fs.file__delete(path_issue)
|
|
81
|
+
deleted_any = True
|
|
82
|
+
|
|
83
|
+
if self.storage_fs.file__exists(path_node):
|
|
84
|
+
self.storage_fs.file__delete(path_node)
|
|
85
|
+
deleted_any = True
|
|
86
|
+
|
|
87
|
+
return deleted_any
|
|
88
|
+
|
|
89
|
+
@type_safe
|
|
90
|
+
def node_exists(self , # Check if node exists (either file)
|
|
91
|
+
node_type : Safe_Str__Node_Type ,
|
|
92
|
+
label : Safe_Str__Node_Label
|
|
93
|
+
) -> bool:
|
|
94
|
+
return self.get_issue_file_path(node_type, label) is not None
|
|
95
|
+
|
|
96
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
97
|
+
# File Path Resolution - Phase 1: Prefer issue.json over node.json
|
|
98
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
@type_safe
|
|
101
|
+
def get_issue_file_path(self , # Get actual issue file path
|
|
102
|
+
node_type : Safe_Str__Node_Type , # Prefers issue.json, falls back to node.json
|
|
103
|
+
label : Safe_Str__Node_Label
|
|
104
|
+
) -> str:
|
|
105
|
+
path_issue = self.path_handler.path_for_issue_json(node_type, label) # Check issue.json first
|
|
106
|
+
if self.storage_fs.file__exists(path_issue):
|
|
107
|
+
return path_issue
|
|
108
|
+
|
|
109
|
+
path_node = self.path_handler.path_for_node_json(node_type, label) # Fall back to node.json
|
|
110
|
+
if self.storage_fs.file__exists(path_node):
|
|
111
|
+
return path_node
|
|
112
|
+
|
|
113
|
+
return None # Neither exists
|
|
114
|
+
|
|
115
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
116
|
+
# Node Listing Operations
|
|
117
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
118
|
+
|
|
119
|
+
@type_safe
|
|
120
|
+
def nodes_list_labels(self , # List all node labels for a type
|
|
121
|
+
node_type : Safe_Str__Node_Type
|
|
122
|
+
) -> List[Safe_Str__Node_Label]:
|
|
123
|
+
type_folder = self.path_handler.path_for_type_folder(node_type)
|
|
124
|
+
all_paths = self.storage_fs.files__paths()
|
|
125
|
+
|
|
126
|
+
labels = set() # Use set to avoid duplicates
|
|
127
|
+
prefix = f"{type_folder}/"
|
|
128
|
+
|
|
129
|
+
for path in all_paths:
|
|
130
|
+
if path.startswith(prefix) is False:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
relative = path[len(prefix):] # Remove prefix
|
|
134
|
+
parts = relative.split('/')
|
|
135
|
+
|
|
136
|
+
if len(parts) >= 2:
|
|
137
|
+
label = parts[0] # First part is label folder
|
|
138
|
+
filename = parts[1] # Second part is filename
|
|
139
|
+
|
|
140
|
+
if filename in ('issue.json', 'node.json'): # Check for either file
|
|
141
|
+
try:
|
|
142
|
+
labels.add(Safe_Str__Node_Label(label))
|
|
143
|
+
except Exception:
|
|
144
|
+
pass # Skip invalid label formats
|
|
145
|
+
|
|
146
|
+
return list(labels)
|
|
147
|
+
|
|
148
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
# Type Index Operations
|
|
150
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
151
|
+
|
|
152
|
+
@type_safe
|
|
153
|
+
def type_index_load(self , # Load per-type index
|
|
154
|
+
node_type : Safe_Str__Node_Type
|
|
155
|
+
) -> Schema__Type__Index:
|
|
156
|
+
path = self.path_handler.path_for_type_index(node_type)
|
|
157
|
+
if self.storage_fs.file__exists(path) is False:
|
|
158
|
+
return Schema__Type__Index(node_type=node_type)
|
|
159
|
+
|
|
160
|
+
content = self.storage_fs.file__str(path)
|
|
161
|
+
if not content:
|
|
162
|
+
return Schema__Type__Index(node_type=node_type)
|
|
163
|
+
|
|
164
|
+
data = json_loads(content)
|
|
165
|
+
if data is None:
|
|
166
|
+
return Schema__Type__Index(node_type=node_type)
|
|
167
|
+
|
|
168
|
+
return Schema__Type__Index.from_json(data)
|
|
169
|
+
|
|
170
|
+
@type_safe
|
|
171
|
+
def type_index_save(self , # Save per-type index
|
|
172
|
+
index : Schema__Type__Index
|
|
173
|
+
) -> bool:
|
|
174
|
+
path = self.path_handler.path_for_type_index(index.node_type)
|
|
175
|
+
data = index.json()
|
|
176
|
+
content = json_dumps(data, indent=2)
|
|
177
|
+
return self.storage_fs.file__save(path, content.encode('utf-8'))
|
|
178
|
+
|
|
179
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
180
|
+
# Global Index Operations
|
|
181
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
182
|
+
|
|
183
|
+
def global_index_load(self) -> Schema__Global__Index: # Load global index
|
|
184
|
+
path = self.path_handler.path_for_global_index()
|
|
185
|
+
if self.storage_fs.file__exists(path) is False:
|
|
186
|
+
return Schema__Global__Index()
|
|
187
|
+
|
|
188
|
+
content = self.storage_fs.file__str(path)
|
|
189
|
+
if not content:
|
|
190
|
+
return Schema__Global__Index()
|
|
191
|
+
|
|
192
|
+
data = json_loads(content)
|
|
193
|
+
if data is None:
|
|
194
|
+
return Schema__Global__Index()
|
|
195
|
+
|
|
196
|
+
return Schema__Global__Index.from_json(data)
|
|
197
|
+
|
|
198
|
+
def global_index_save(self, index: Schema__Global__Index) -> bool: # Save global index
|
|
199
|
+
path = self.path_handler.path_for_global_index()
|
|
200
|
+
data = index.json()
|
|
201
|
+
content = json_dumps(data, indent=2)
|
|
202
|
+
return self.storage_fs.file__save(path, content.encode('utf-8'))
|
|
203
|
+
|
|
204
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
205
|
+
# Config Operations - Node Types
|
|
206
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
207
|
+
|
|
208
|
+
def node_types_load(self) -> List[Schema__Node__Type]: # Load all node types
|
|
209
|
+
path = self.path_handler.path_for_node_types()
|
|
210
|
+
if self.storage_fs.file__exists(path) is False:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
content = self.storage_fs.file__str(path)
|
|
214
|
+
if not content:
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
data = json_loads(content)
|
|
218
|
+
if data is None or 'types' not in data:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
types = []
|
|
222
|
+
for item in data['types']:
|
|
223
|
+
types.append(Schema__Node__Type.from_json(item))
|
|
224
|
+
return types
|
|
225
|
+
|
|
226
|
+
def node_types_save(self, types: List[Schema__Node__Type]) -> bool: # Save all node types
|
|
227
|
+
path = self.path_handler.path_for_node_types()
|
|
228
|
+
data = {'types': [t.json() for t in types]}
|
|
229
|
+
content = json_dumps(data, indent=2)
|
|
230
|
+
return self.storage_fs.file__save(path, content.encode('utf-8'))
|
|
231
|
+
|
|
232
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
233
|
+
# Config Operations - Link Types
|
|
234
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
235
|
+
|
|
236
|
+
def link_types_load(self) -> List[Schema__Link__Type]: # Load all link types
|
|
237
|
+
path = self.path_handler.path_for_link_types()
|
|
238
|
+
if self.storage_fs.file__exists(path) is False:
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
content = self.storage_fs.file__str(path)
|
|
242
|
+
if not content:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
data = json_loads(content)
|
|
246
|
+
if data is None or 'link_types' not in data:
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
types = []
|
|
250
|
+
for item in data['link_types']:
|
|
251
|
+
types.append(Schema__Link__Type.from_json(item))
|
|
252
|
+
return types
|
|
253
|
+
|
|
254
|
+
def link_types_save(self, types: List[Schema__Link__Type]) -> bool: # Save all link types
|
|
255
|
+
path = self.path_handler.path_for_link_types()
|
|
256
|
+
data = {'link_types': [t.json() for t in types]}
|
|
257
|
+
content = json_dumps(data, indent=2)
|
|
258
|
+
return self.storage_fs.file__save(path, content.encode('utf-8'))
|
|
259
|
+
|
|
260
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
261
|
+
# Attachment Operations
|
|
262
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
263
|
+
|
|
264
|
+
@type_safe
|
|
265
|
+
def attachment_save(self , # Save attachment (raw bytes)
|
|
266
|
+
node_type : Safe_Str__Node_Type ,
|
|
267
|
+
label : Safe_Str__Node_Label ,
|
|
268
|
+
filename : str ,
|
|
269
|
+
data : bytes
|
|
270
|
+
) -> bool:
|
|
271
|
+
path = self.path_handler.path_for_attachment(node_type = node_type ,
|
|
272
|
+
label = label ,
|
|
273
|
+
filename = filename )
|
|
274
|
+
return self.storage_fs.file__save(path, data)
|
|
275
|
+
|
|
276
|
+
@type_safe
|
|
277
|
+
def attachment_load(self , # Load attachment
|
|
278
|
+
node_type : Safe_Str__Node_Type ,
|
|
279
|
+
label : Safe_Str__Node_Label ,
|
|
280
|
+
filename : str
|
|
281
|
+
) -> bytes:
|
|
282
|
+
path = self.path_handler.path_for_attachment(node_type = node_type ,
|
|
283
|
+
label = label ,
|
|
284
|
+
filename = filename )
|
|
285
|
+
if self.storage_fs.file__exists(path) is False:
|
|
286
|
+
return None
|
|
287
|
+
return self.storage_fs.file__bytes(path)
|
|
288
|
+
|
|
289
|
+
@type_safe
|
|
290
|
+
def attachment_delete(self , # Delete attachment
|
|
291
|
+
node_type : Safe_Str__Node_Type ,
|
|
292
|
+
label : Safe_Str__Node_Label ,
|
|
293
|
+
filename : str
|
|
294
|
+
) -> bool:
|
|
295
|
+
path = self.path_handler.path_for_attachment(node_type = node_type ,
|
|
296
|
+
label = label ,
|
|
297
|
+
filename = filename )
|
|
298
|
+
if self.storage_fs.file__exists(path):
|
|
299
|
+
return self.storage_fs.file__delete(path)
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
303
|
+
# Utility Operations
|
|
304
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
305
|
+
|
|
306
|
+
def clear_storage(self) -> None: # Clear all data (for tests)
|
|
307
|
+
self.storage_fs.clear()
|