ms-graph-toolbox 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 runway28R
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: ms-graph-toolbox
3
+ Version: 0.1.0
4
+ Summary: A lightweight Python wrapper around Microsoft Graph for email, users, and SharePoint operations.
5
+ Author: runway28R
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/runway28R/ms-graph-toolbox
8
+ Project-URL: Source, https://github.com/runway28R/ms-graph-toolbox
9
+ Project-URL: Issues, https://github.com/runway28R/ms-graph-toolbox/issues
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: requests>=2.31.0
14
+ Requires-Dist: msal>=1.27.0
15
+ Dynamic: license-file
16
+
17
+ # Microsoft Graph Toolbox
18
+
19
+ A lightweight Python wrapper around Microsoft Graph for sending email, retrieving users, and working with SharePoint files.
20
+
21
+ ## Features
22
+
23
+ - Authenticate with Microsoft Graph using Microsoft Entra ID application credentials
24
+ - Send email messages
25
+ - Retrieve users from Microsoft Graph
26
+ - List SharePoint sites and document libraries
27
+ - Browse folders and upload files to SharePoint
28
+
29
+ ## Requirements
30
+
31
+ - Python 3.12 or newer
32
+ - A Microsoft Entra ID application registration
33
+ - Microsoft Graph API permissions
34
+ - A Microsoft 365 account with access to the required resources
35
+
36
+ ## Installation
37
+
38
+ Install the package from PyPI:
39
+
40
+ ```bash
41
+ pip install ms-graph-toolbox
42
+ ```
43
+
44
+ ## Authentication
45
+
46
+ The package uses application credentials:
47
+
48
+ - Client ID
49
+ - Client secret
50
+ - Tenant ID
51
+
52
+ Make sure the application has the required Microsoft Graph permissions and that administrator consent has been granted where necessary.
53
+
54
+ ## Basic Usage
55
+
56
+ ```python
57
+ import logging
58
+ import os
59
+
60
+ from ms_graph_toolbox.ms_graph_toolbox import ms_graph_toolbox
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ graph = ms_graph_toolbox(
65
+ client_id=os.environ["MS_GRAPH_CLIENT_ID"],
66
+ client_secret=os.environ["MS_GRAPH_CLIENT_SECRET"],
67
+ tenant_id=os.environ["MS_GRAPH_TENANT_ID"],
68
+ logger=logger,
69
+ )
70
+ ```
71
+
72
+ The object automatically obtains an access token that is used by the other package functions.
73
+
74
+ ## Sending Email
75
+
76
+ ```python
77
+ from ms_graph_toolbox.graph_email import send_email
78
+
79
+ send_email(
80
+ gph_object=graph,
81
+ subject="Test message",
82
+ content_type="Text",
83
+ body="This message was sent using Microsoft Graph.",
84
+ sender="sender@example.com",
85
+ to_field="recipient@example.com",
86
+ )
87
+ ```
88
+
89
+ Additional options include carbon-copy recipients, blind-carbon-copy recipients, message priority, and file attachments.
90
+
91
+ Multiple recipients can be provided as a comma-separated string:
92
+
93
+ ```python
94
+ send_email(
95
+ gph_object=graph,
96
+ subject="Team update",
97
+ content_type="Text",
98
+ body="This message was sent to multiple recipients.",
99
+ sender="sender@example.com",
100
+ to_field="first@example.com, second@example.com",
101
+ )
102
+ ```
103
+
104
+ ## Retrieving Users
105
+
106
+ ```python
107
+ from ms_graph_toolbox.graph_users import get_users
108
+
109
+ users = get_users(graph)
110
+
111
+ for user in users:
112
+ print(user)
113
+ ```
114
+
115
+ You can also filter the results by name, title, email address, alias, company, or selected fields:
116
+
117
+ ```python
118
+ users = get_users(
119
+ graph,
120
+ select_data=["displayName", "mail", "jobTitle"],
121
+ search_name="Alex",
122
+ )
123
+ ```
124
+
125
+ ## SharePoint Operations
126
+
127
+ SharePoint functionality is available through the `graph_sharepoint` class:
128
+
129
+ ```python
130
+ from ms_graph_toolbox.graph_sharepoint import graph_sharepoint
131
+
132
+ sharepoint = graph_sharepoint(
133
+ access_token=graph.access_token,
134
+ logger=logger,
135
+ )
136
+ ```
137
+
138
+ The SharePoint tools support:
139
+
140
+ - Finding a SharePoint site
141
+ - Listing document libraries
142
+ - Browsing folder contents
143
+ - Uploading files
144
+
145
+ See the [examples](https://github.com/runway28R/ms-graph-toolbox/tree/main/examples) directory for complete SharePoint usage examples.
146
+
147
+ ## Examples
148
+
149
+ The repository contains complete examples for:
150
+
151
+ - Sending email
152
+ - Retrieving users
153
+ - Uploading files to SharePoint
154
+
155
+ The examples are available in the [examples](https://github.com/runway28R/ms-graph-toolbox/tree/main/examples) directory.
156
+
157
+ ## Configuration
158
+
159
+ For local development, set these environment variables before running the examples.
160
+
161
+ ### PowerShell
162
+
163
+ ```powershell
164
+ $env:MS_GRAPH_CLIENT_ID = "your-client-id"
165
+ $env:MS_GRAPH_CLIENT_SECRET = "your-client-secret"
166
+ $env:MS_GRAPH_TENANT_ID = "your-tenant-id"
167
+ ```
168
+
169
+ ### Bash
170
+
171
+ ```bash
172
+ export MS_GRAPH_CLIENT_ID="your-client-id"
173
+ export MS_GRAPH_CLIENT_SECRET="your-client-secret"
174
+ export MS_GRAPH_TENANT_ID="your-tenant-id"
175
+ ```
176
+
177
+ Do not commit client secrets or other credentials to the repository.
178
+
179
+ ## License
180
+
181
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,165 @@
1
+ # Microsoft Graph Toolbox
2
+
3
+ A lightweight Python wrapper around Microsoft Graph for sending email, retrieving users, and working with SharePoint files.
4
+
5
+ ## Features
6
+
7
+ - Authenticate with Microsoft Graph using Microsoft Entra ID application credentials
8
+ - Send email messages
9
+ - Retrieve users from Microsoft Graph
10
+ - List SharePoint sites and document libraries
11
+ - Browse folders and upload files to SharePoint
12
+
13
+ ## Requirements
14
+
15
+ - Python 3.12 or newer
16
+ - A Microsoft Entra ID application registration
17
+ - Microsoft Graph API permissions
18
+ - A Microsoft 365 account with access to the required resources
19
+
20
+ ## Installation
21
+
22
+ Install the package from PyPI:
23
+
24
+ ```bash
25
+ pip install ms-graph-toolbox
26
+ ```
27
+
28
+ ## Authentication
29
+
30
+ The package uses application credentials:
31
+
32
+ - Client ID
33
+ - Client secret
34
+ - Tenant ID
35
+
36
+ Make sure the application has the required Microsoft Graph permissions and that administrator consent has been granted where necessary.
37
+
38
+ ## Basic Usage
39
+
40
+ ```python
41
+ import logging
42
+ import os
43
+
44
+ from ms_graph_toolbox.ms_graph_toolbox import ms_graph_toolbox
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ graph = ms_graph_toolbox(
49
+ client_id=os.environ["MS_GRAPH_CLIENT_ID"],
50
+ client_secret=os.environ["MS_GRAPH_CLIENT_SECRET"],
51
+ tenant_id=os.environ["MS_GRAPH_TENANT_ID"],
52
+ logger=logger,
53
+ )
54
+ ```
55
+
56
+ The object automatically obtains an access token that is used by the other package functions.
57
+
58
+ ## Sending Email
59
+
60
+ ```python
61
+ from ms_graph_toolbox.graph_email import send_email
62
+
63
+ send_email(
64
+ gph_object=graph,
65
+ subject="Test message",
66
+ content_type="Text",
67
+ body="This message was sent using Microsoft Graph.",
68
+ sender="sender@example.com",
69
+ to_field="recipient@example.com",
70
+ )
71
+ ```
72
+
73
+ Additional options include carbon-copy recipients, blind-carbon-copy recipients, message priority, and file attachments.
74
+
75
+ Multiple recipients can be provided as a comma-separated string:
76
+
77
+ ```python
78
+ send_email(
79
+ gph_object=graph,
80
+ subject="Team update",
81
+ content_type="Text",
82
+ body="This message was sent to multiple recipients.",
83
+ sender="sender@example.com",
84
+ to_field="first@example.com, second@example.com",
85
+ )
86
+ ```
87
+
88
+ ## Retrieving Users
89
+
90
+ ```python
91
+ from ms_graph_toolbox.graph_users import get_users
92
+
93
+ users = get_users(graph)
94
+
95
+ for user in users:
96
+ print(user)
97
+ ```
98
+
99
+ You can also filter the results by name, title, email address, alias, company, or selected fields:
100
+
101
+ ```python
102
+ users = get_users(
103
+ graph,
104
+ select_data=["displayName", "mail", "jobTitle"],
105
+ search_name="Alex",
106
+ )
107
+ ```
108
+
109
+ ## SharePoint Operations
110
+
111
+ SharePoint functionality is available through the `graph_sharepoint` class:
112
+
113
+ ```python
114
+ from ms_graph_toolbox.graph_sharepoint import graph_sharepoint
115
+
116
+ sharepoint = graph_sharepoint(
117
+ access_token=graph.access_token,
118
+ logger=logger,
119
+ )
120
+ ```
121
+
122
+ The SharePoint tools support:
123
+
124
+ - Finding a SharePoint site
125
+ - Listing document libraries
126
+ - Browsing folder contents
127
+ - Uploading files
128
+
129
+ See the [examples](https://github.com/runway28R/ms-graph-toolbox/tree/main/examples) directory for complete SharePoint usage examples.
130
+
131
+ ## Examples
132
+
133
+ The repository contains complete examples for:
134
+
135
+ - Sending email
136
+ - Retrieving users
137
+ - Uploading files to SharePoint
138
+
139
+ The examples are available in the [examples](https://github.com/runway28R/ms-graph-toolbox/tree/main/examples) directory.
140
+
141
+ ## Configuration
142
+
143
+ For local development, set these environment variables before running the examples.
144
+
145
+ ### PowerShell
146
+
147
+ ```powershell
148
+ $env:MS_GRAPH_CLIENT_ID = "your-client-id"
149
+ $env:MS_GRAPH_CLIENT_SECRET = "your-client-secret"
150
+ $env:MS_GRAPH_TENANT_ID = "your-tenant-id"
151
+ ```
152
+
153
+ ### Bash
154
+
155
+ ```bash
156
+ export MS_GRAPH_CLIENT_ID="your-client-id"
157
+ export MS_GRAPH_CLIENT_SECRET="your-client-secret"
158
+ export MS_GRAPH_TENANT_ID="your-tenant-id"
159
+ ```
160
+
161
+ Do not commit client secrets or other credentials to the repository.
162
+
163
+ ## License
164
+
165
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
File without changes
@@ -0,0 +1,173 @@
1
+ import requests
2
+ import base64
3
+ import os
4
+ import mimetypes
5
+
6
+
7
+ def send_email(gph_object,
8
+ subject,
9
+ content_type,
10
+ body,
11
+ sender,
12
+ to_field,
13
+ cc_field=None,
14
+ bcc_field=None,
15
+ priority="Normal",
16
+ attachments=None):
17
+ """
18
+ Send an email using Microsoft Graph on behalf of `sender`.
19
+
20
+ Parameters:
21
+ logger: logger instance to record actions (debug/error).
22
+ subject: email subject string.
23
+ content_type: one of 'text', 'html', 'text/plain', 'text/html', etc.
24
+ body: email body string.
25
+ to_field, cc_field, bcc_field: comma-separated recipient strings (e.g. "a@x.com, b@y.com").
26
+ priority: message importance, e.g. "Low", "Normal", "High".
27
+ attachments: optional list of attachment descriptors. Each descriptor may be:
28
+ - {'path': 'C:\\full\\path\\file.pdf', 'name': 'file.pdf', 'content_type': 'application/pdf', 'inline': False}
29
+ - {'content_bytes': b'...', 'name': 'image.png', 'content_type': 'image/png', 'inline': True, 'content_id': 'img1'}
30
+ For inline images set 'inline': True and reference them in an HTML body as <img src="cid:content_id">.
31
+ If content_type not provided it will be guessed from the filename.
32
+ The code will base64-encode file contents as required by Graph.
33
+
34
+ Returns:
35
+ 0 on success (202 response), 1 on exception, 2 if no access token, 3 on non-202 HTTP response.
36
+ """
37
+ try:
38
+ # Ensure we have an access token before attempting to send
39
+ if not gph_object.access_token:
40
+ gph_object.logger.error("Invalid Access Token, email cannot be sent!")
41
+ return 2
42
+
43
+ # Parse recipient fields into Graph-friendly lists
44
+ to_recipients = parse_recipients(to_field)
45
+ cc_recipients = parse_recipients(cc_field)
46
+ bcc_recipients = parse_recipients(bcc_field)
47
+
48
+ # Normalize content type to one of Graph's expected values ("Text" or "HTML"), use "Text" as default
49
+ valid_types = {"text": "Text", "plain": "Text", "html": "HTML", "text/plain": "Text", "text/html": "HTML"}
50
+ content_type_normalized = valid_types.get(content_type.strip().lower(), "Text")
51
+
52
+ # Process attachments and if any contain inline items but the body is not HTML, log a warning
53
+ inline_found = False
54
+ attachments_payload = []
55
+ if attachments:
56
+ # Process all attachments in one pass
57
+ attachments_payload = [
58
+ att for desc in attachments
59
+ if (att := build_attachment(descriptor=desc, logger=gph_object.logger)) is not None
60
+ ]
61
+
62
+ # Check for inline attachments
63
+ inline_found = any(att.get("isInline") for att in attachments_payload)
64
+
65
+ # Log warning if inline attachments found with non-HTML content
66
+ if inline_found and content_type_normalized != "HTML":
67
+ gph_object.logger.warning("Inline attachments present but body is not HTML. Inline images require HTML body and <img src=\"cid:contentId\"> references.")
68
+
69
+ # Build sendMail endpoint for the configured sender user
70
+ endpoint = f"https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
71
+
72
+ # Compose message payload according to Graph sendMail schema
73
+ email_msg = {
74
+ "message": {
75
+ "subject": subject,
76
+ "body": {
77
+ "contentType": content_type_normalized,
78
+ "content": body
79
+ },
80
+ "toRecipients": to_recipients,
81
+ "importance": priority
82
+ }
83
+ }
84
+
85
+ # Attach cc/bcc only if provided
86
+ if cc_recipients:
87
+ email_msg["message"]["ccRecipients"] = cc_recipients
88
+ if bcc_recipients:
89
+ email_msg["message"]["bccRecipients"] = bcc_recipients
90
+
91
+ # Attach attachments if any
92
+ if attachments_payload:
93
+ email_msg["message"]["attachments"] = attachments_payload
94
+
95
+ # Set authorization header with Bearer token
96
+ headers = {"Authorization": f"Bearer {gph_object.access_token}", "Content-Type": "application/json"}
97
+ gph_object.logger.debug(f"Sending email using MS Graph from {sender}")
98
+ response = requests.post(endpoint, headers=headers, json=email_msg)
99
+
100
+ # 202 Accepted indicates Graph accepted the send request
101
+ if response.status_code == 202:
102
+ gph_object.logger.debug("Sending successful!")
103
+ return 0
104
+ else:
105
+ # Log status and response body for debugging failures
106
+ gph_object.logger.error(f"Sending Failed: {response.status_code}, {response.text}")
107
+ return 3
108
+ except Exception as e:
109
+ # Any unexpected exception during send is logged
110
+ gph_object.logger.error(f"Sending Failed: {e}")
111
+ return 1
112
+
113
+
114
+ # Convert a comma-separated string of addresses into the Graph recipient JSON format.
115
+ def parse_recipients(field):
116
+ if not field:
117
+ return []
118
+ return [{"emailAddress": {"address": addr.strip()}} for addr in field.split(",") if addr.strip()]
119
+
120
+
121
+ # Build Graph attachment payloads from descriptors
122
+ def build_attachment(descriptor, logger):
123
+ """
124
+ Accepts either:
125
+ - {'path': 'C:\\file', ...}
126
+ - {'content_bytes': b'...', ...}
127
+ Returns a dict suitable for Graph message attachments.
128
+ """
129
+ try:
130
+ # Basic check
131
+ if "path" not in descriptor and "content_bytes" not in descriptor:
132
+ logger.error(f"build_attachment failed: Descriptor missing 'path' or 'content_bytes': {descriptor}")
133
+ return None
134
+ # Determine name
135
+ name = descriptor.get("name")
136
+ content_type_guess = None
137
+
138
+ # Load bytes from file path if provided
139
+ if "path" in descriptor and descriptor["path"]:
140
+ path = descriptor["path"]
141
+ with open(path, "rb") as f:
142
+ data = f.read()
143
+ if not name:
144
+ name = os.path.basename(path)
145
+ content_type_guess = mimetypes.guess_type(path)[0]
146
+ elif "content_bytes" in descriptor and descriptor["content_bytes"] is not None:
147
+ data = descriptor["content_bytes"]
148
+ if isinstance(data, str):
149
+ data = data.encode("utf-8")
150
+
151
+ # Determine contentType
152
+ content_type = descriptor.get("content_type") or content_type_guess or "application/octet-stream"
153
+
154
+ # Base64 encode content
155
+ content_bytes_b64 = base64.b64encode(data).decode("utf-8")
156
+
157
+ # Build attachment object (fileAttachment)
158
+ attachment = {
159
+ "@odata.type": "#microsoft.graph.fileAttachment",
160
+ "name": name or "attachment",
161
+ "contentType": content_type,
162
+ "contentBytes": content_bytes_b64
163
+ }
164
+
165
+ # Inline settings for images
166
+ if descriptor.get("inline"):
167
+ attachment["isInline"] = True
168
+ # contentId is used as cid reference in HTML body: <img src="cid:contentId">
169
+ attachment["contentId"] = descriptor.get("content_id") or (name or "inline")
170
+ return attachment
171
+ except Exception as e:
172
+ logger.error(f"build_attachment: failed for descriptor {descriptor}: {e}")
173
+ return None
@@ -0,0 +1,109 @@
1
+ from urllib.parse import quote
2
+ import pathlib as pl
3
+ import requests
4
+
5
+
6
+ class graph_sharepoint:
7
+ def __init__(self, access_token:str, logger):
8
+ self.access_token = access_token
9
+ self.logger = logger
10
+
11
+
12
+ def get_site_id(self, site_url:str):
13
+ # Request site ID
14
+ try:
15
+ full_url = f'https://graph.microsoft.com/v1.0/sites/{site_url}'
16
+ response = requests.get(full_url,
17
+ headers={'Authorization': f'Bearer {self.access_token}'})
18
+ self.logger.debug(f"get_site_id response: {response.status_code} - {response.text}")
19
+ return response.json().get('id') # Return the site ID
20
+ except Exception as e:
21
+ self.logger.error(f"get_site_id failed: {e}")
22
+ return None
23
+
24
+
25
+ def get_document_libraries(self, site_id:str):
26
+ # Retrieve drive IDs and names associated with a site
27
+ try:
28
+ drives_url = f'https://graph.microsoft.com/v1.0/sites/{site_id}/drives'
29
+ response = requests.get(drives_url, headers={'Authorization': f'Bearer {self.access_token}'})
30
+ drives = response.json().get('value', [])
31
+ return [(drive['id'], drive['name']) for drive in drives]
32
+ except Exception as e:
33
+ self.logger.error(f"get_document_libraries failed: {e}")
34
+ return None
35
+
36
+
37
+ def get_folder_content(self, site_id:str, drive_id:str):
38
+ # Get the contents of a folder
39
+ try:
40
+ folder_url = f'https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}/root/children'
41
+ response = requests.get(folder_url, headers={'Authorization': f'Bearer {self.access_token}'})
42
+ return response.json().get('value', [])
43
+ except Exception as e:
44
+ self.logger.error(f"get_folder_content failed: {e}")
45
+ return None
46
+
47
+ def print_folder_content(self, folder_content):
48
+ # Display the contents of a SharePoint folder
49
+ folders = []
50
+ files = []
51
+
52
+ for item in folder_content:
53
+ if "folder" in item:
54
+ folders.append(item["name"])
55
+ elif "file" in item:
56
+ files.append(item["name"])
57
+
58
+ print(f"Folders: {len(folders)}")
59
+ for f in sorted(folders):
60
+ print(f)
61
+
62
+ print(f"Files: {len(files)}")
63
+ for f in sorted(files):
64
+ print(f)
65
+
66
+
67
+ def upload_file_graph(self, site_id, drive_id:str, folder_path:str, local_file_path:str):
68
+ """
69
+ Uploads a file to a SharePoint folder using Microsoft Graph API.
70
+
71
+ :param site_id: The SharePoint site ID
72
+ :param drive_id: The drive ID for the desired folder
73
+ :param folder_path: Path inside taht folder
74
+ :param local_file_path: Local path of the file to be uploaded
75
+ :return: (file_url, success_file_count)
76
+ """
77
+ try:
78
+ file_path = pl.Path(local_file_path)
79
+ if not file_path.is_file():
80
+ raise FileNotFoundError(f"File not found: {file_path}")
81
+
82
+ with open(file_path, "rb") as file:
83
+ file_content = file.read()
84
+
85
+ # Build the upload URL
86
+ folder_path_encoded = quote(folder_path)
87
+ upload_url = (
88
+ f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}/root:/{folder_path_encoded}/{file_path.name}:/content"
89
+ )
90
+
91
+ headers = {
92
+ "Authorization": f"Bearer {self.access_token}",
93
+ "Content-Type": "application/octet-stream"
94
+ }
95
+
96
+ response = requests.put(upload_url, headers=headers, data=file_content)
97
+ if response.status_code in [200, 201]:
98
+ file_info = response.json()
99
+ file_url = file_info.get("webUrl", "")
100
+ self.logger.debug(f"File uploaded to: {file_url}")
101
+ return file_url, 1
102
+ else:
103
+ self.logger.error(f"Upload to {upload_url} failed: {response.text}")
104
+ return response.text, 0
105
+ except FileNotFoundError as e:
106
+ return str(e), 0
107
+ except Exception as e:
108
+ self.logger.error(f"upload_file_graph failed: {e}")
109
+ return str(e), 0