ivoryos 0.1.21__tar.gz → 0.1.22__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.

Potentially problematic release.


This version of ivoryos might be problematic. Click here for more details.

Files changed (56) hide show
  1. {ivoryos-0.1.21/ivoryos.egg-info → ivoryos-0.1.22}/PKG-INFO +7 -3
  2. {ivoryos-0.1.21 → ivoryos-0.1.22}/README.md +6 -2
  3. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/__init__.py +13 -5
  4. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/control/control.py +2 -2
  5. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/control/templates/control/controllers_home.html +6 -1
  6. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/database/database.py +43 -2
  7. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/database/templates/database/experiment_database.html +3 -3
  8. ivoryos-0.1.22/ivoryos/routes/database/templates/database/workflow_run_database.html +81 -0
  9. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/design/design.py +16 -2
  10. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/design/templates/design/experiment_run.html +159 -81
  11. ivoryos-0.1.22/ivoryos/routes/main/templates/main/home.html +103 -0
  12. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/templates/base.html +6 -3
  13. ivoryos-0.1.22/ivoryos/utils/client_proxy.py +57 -0
  14. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/db_models.py +43 -1
  15. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/llm_agent.py +1 -1
  16. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/script_runner.py +91 -42
  17. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/utils.py +23 -0
  18. ivoryos-0.1.22/ivoryos/version.py +1 -0
  19. {ivoryos-0.1.21 → ivoryos-0.1.22/ivoryos.egg-info}/PKG-INFO +7 -3
  20. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos.egg-info/SOURCES.txt +2 -0
  21. ivoryos-0.1.21/ivoryos/routes/main/templates/main/home.html +0 -70
  22. ivoryos-0.1.21/ivoryos/version.py +0 -1
  23. {ivoryos-0.1.21 → ivoryos-0.1.22}/LICENSE +0 -0
  24. {ivoryos-0.1.21 → ivoryos-0.1.22}/MANIFEST.in +0 -0
  25. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/config.py +0 -0
  26. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/__init__.py +0 -0
  27. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/auth/__init__.py +0 -0
  28. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/auth/auth.py +0 -0
  29. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/auth/templates/auth/login.html +0 -0
  30. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/auth/templates/auth/signup.html +0 -0
  31. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/control/__init__.py +0 -0
  32. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/control/templates/control/controllers.html +0 -0
  33. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/control/templates/control/controllers_new.html +0 -0
  34. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/database/__init__.py +0 -0
  35. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/design/__init__.py +0 -0
  36. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/design/templates/design/experiment_builder.html +0 -0
  37. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/main/__init__.py +0 -0
  38. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/main/main.py +0 -0
  39. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/routes/main/templates/main/help.html +0 -0
  40. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/favicon.ico +0 -0
  41. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/gui_annotation/Slide1.png +0 -0
  42. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/gui_annotation/Slide2.PNG +0 -0
  43. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/js/overlay.js +0 -0
  44. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/js/socket_handler.js +0 -0
  45. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/js/sortable_card.js +0 -0
  46. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/js/sortable_design.js +0 -0
  47. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/logo.webp +0 -0
  48. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/static/style.css +0 -0
  49. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/__init__.py +0 -0
  50. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/form.py +0 -0
  51. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos/utils/global_config.py +0 -0
  52. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos.egg-info/dependency_links.txt +0 -0
  53. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos.egg-info/requires.txt +0 -0
  54. {ivoryos-0.1.21 → ivoryos-0.1.22}/ivoryos.egg-info/top_level.txt +0 -0
  55. {ivoryos-0.1.21 → ivoryos-0.1.22}/setup.cfg +0 -0
  56. {ivoryos-0.1.21 → ivoryos-0.1.22}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ivoryos
3
- Version: 0.1.21
3
+ Version: 0.1.22
4
4
  Summary: an open-source Python package enabling Self-Driving Labs (SDLs) interoperability
5
5
  Home-page: https://gitlab.com/heingroup/ivoryos
6
6
  Author: Ivory Zhang
@@ -28,7 +28,7 @@ License-File: LICENSE
28
28
  - [Instructions for use](#instructions-for-use)
29
29
  - [Demo](#demo)
30
30
  - [Roadmap](#roadmap)
31
- - [License](#license)
31
+
32
32
 
33
33
  ## Description
34
34
  Granting SDLs flexibility and modularity makes it almost impossible to design a UI, yet it's a necessity for allowing more people to interact with it (democratisation).
@@ -85,6 +85,10 @@ Create an account and login (local database)
85
85
  - **Database**: manage workflows in _Library_ tab.
86
86
  - **Info page**: additional info in _About_ tab.
87
87
 
88
+ [//]: # (![Discord](https://img.shields.io/discord/1313641159356059770))
89
+
90
+ [//]: # (![PyPI - Downloads](https://img.shields.io/pypi/dm/ivoryos))
91
+
88
92
 
89
93
  ### Additional settings
90
94
  #### AI assistant
@@ -125,7 +129,7 @@ After one successful connection, a blueprint will be automatically saved and mad
125
129
  ivoryos.run()
126
130
  ```
127
131
  ## Demo
128
- In the [abstract_sdl.py](https://gitlab.com/heingroup/ivoryos/-/blob/main/example/sdl_example/abstract_sdl.py), where instances of `AbstractSDL` is created as `sdl`,
132
+ In the [abstract_sdl.py](https://gitlab.com/heingroup/ivoryos/-/blob/main/example/abstract_sdl_example/abstract_sdl.py), where instances of `AbstractSDL` is created as `sdl`,
129
133
  addresses will be available on terminal.
130
134
  ```Python
131
135
  ivoryos.run(__name__)
@@ -16,7 +16,7 @@
16
16
  - [Instructions for use](#instructions-for-use)
17
17
  - [Demo](#demo)
18
18
  - [Roadmap](#roadmap)
19
- - [License](#license)
19
+
20
20
 
21
21
  ## Description
22
22
  Granting SDLs flexibility and modularity makes it almost impossible to design a UI, yet it's a necessity for allowing more people to interact with it (democratisation).
@@ -73,6 +73,10 @@ Create an account and login (local database)
73
73
  - **Database**: manage workflows in _Library_ tab.
74
74
  - **Info page**: additional info in _About_ tab.
75
75
 
76
+ [//]: # (![Discord](https://img.shields.io/discord/1313641159356059770))
77
+
78
+ [//]: # (![PyPI - Downloads](https://img.shields.io/pypi/dm/ivoryos))
79
+
76
80
 
77
81
  ### Additional settings
78
82
  #### AI assistant
@@ -113,7 +117,7 @@ After one successful connection, a blueprint will be automatically saved and mad
113
117
  ivoryos.run()
114
118
  ```
115
119
  ## Demo
116
- In the [abstract_sdl.py](https://gitlab.com/heingroup/ivoryos/-/blob/main/example/sdl_example/abstract_sdl.py), where instances of `AbstractSDL` is created as `sdl`,
120
+ In the [abstract_sdl.py](https://gitlab.com/heingroup/ivoryos/-/blob/main/example/abstract_sdl_example/abstract_sdl.py), where instances of `AbstractSDL` is created as `sdl`,
117
121
  addresses will be available on terminal.
118
122
  ```Python
119
123
  ivoryos.run(__name__)
@@ -18,6 +18,16 @@ from ivoryos.utils.script_runner import ScriptRunner
18
18
  from ivoryos.version import __version__ as ivoryos_version
19
19
  from importlib.metadata import entry_points
20
20
  global_config = GlobalConfig()
21
+ from sqlalchemy import event
22
+ from sqlalchemy.engine import Engine
23
+ import sqlite3
24
+
25
+ @event.listens_for(Engine, "connect")
26
+ def enforce_sqlite_foreign_keys(dbapi_connection, connection_record):
27
+ if isinstance(dbapi_connection, sqlite3.Connection):
28
+ cursor = dbapi_connection.cursor()
29
+ cursor.execute("PRAGMA foreign_keys=ON")
30
+ cursor.close()
21
31
 
22
32
  url_prefix = os.getenv('URL_PREFIX', "/ivoryos")
23
33
  app = Flask(__name__, static_url_path=f'{url_prefix}/static', static_folder='static')
@@ -29,8 +39,9 @@ app.register_blueprint(database, url_prefix=url_prefix)
29
39
 
30
40
 
31
41
  def create_app(config_class=None):
32
- # url_prefix = os.getenv('URL_PREFIX', "/ivoryos")
33
- # app = Flask(__name__, static_url_path=f'{url_prefix}/static', static_folder='static')
42
+ """
43
+ create app, init database
44
+ """
34
45
  app.config.from_object(config_class or 'config.get_config()')
35
46
 
36
47
  # Initialize extensions
@@ -85,7 +96,6 @@ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, mod
85
96
  :param logger: logger name of list of logger names, defaults to None
86
97
  :param logger_output_name: log file save name of logger, defaults to None, and will use "default.log"
87
98
  :param enable_design: enable design canvas, database and workflow execution
88
- :param stream_address:
89
99
  """
90
100
  app = create_app(config_class=config or get_config()) # Create app instance using factory function
91
101
 
@@ -110,10 +120,8 @@ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, mod
110
120
  app.config["MODULE"] = module
111
121
  app.config["OFF_LINE"] = False
112
122
  global_config.deck = sys.modules[module]
113
- # global_config.heinsight = HeinsightAPI("http://127.0.0.1:8080")
114
123
  global_config.deck_snapshot = utils.create_deck_snapshot(global_config.deck,
115
124
  output_path=app.config["DUMMY_DECK"], save=True)
116
- # global_config.runner = ScriptRunner(globals())
117
125
  else:
118
126
  app.config["OFF_LINE"] = True
119
127
  if model:
@@ -151,7 +151,7 @@ def controllers(instrument: str):
151
151
  return render_template('controllers.html', instrument=instrument, forms=forms, format_name=format_name)
152
152
 
153
153
 
154
- @control.route("/backend_control/<instrument>", methods=['GET', 'POST'])
154
+ @control.route("/backend_control/<instrument>", methods=['POST'])
155
155
  def backend_control(instrument: str=None):
156
156
  """
157
157
  .. :quickref: Backend Control; backend control
@@ -187,7 +187,7 @@ def backend_control(instrument: str=None):
187
187
  return json_output, 400
188
188
  else:
189
189
  return "instrument not exist", 400
190
- return json_output, 200
190
+ return json_output, 200
191
191
 
192
192
 
193
193
  @control.route("/backend_control", methods=['GET'])
@@ -9,7 +9,7 @@
9
9
  {# {% if not deck %}#}
10
10
  {# <a href="{{ url_for('control.disconnect', instrument=instrument) }}" class="stretched-link controller-card" style="float: right;color: red; position: relative;">Disconnect <i class="bi bi-x-square"></i></a>#}
11
11
  <div class="p-4 controller-card">
12
- <h5 class=""><a href="{{ url_for('control.controllers', instrument=instrument) }}" class="text-dark stretched-link">{{instrument}}</a></h5>
12
+ <h5 class=""><a href="{{ url_for('control.controllers', instrument=instrument) }}" class="text-dark stretched-link">{{instrument.split(".")[1]}}</a></h5>
13
13
  </div>
14
14
  {# {% else %}#}
15
15
  {# <div class="p-4 controller-card">#}
@@ -19,6 +19,11 @@
19
19
  </div>
20
20
  </div>
21
21
  {% endfor %}
22
+ <div class="d-flex mb-3">
23
+ <a href="{{ url_for('design.download', filetype='proxy') }}" class="btn btn-outline-primary">
24
+ <i class="bi bi-download"></i> Download remote control script
25
+ </a>
26
+ </div>
22
27
  {% if not deck %}
23
28
  <div class="col-xl-3 col-lg-4 col-md-6 mb-4 ">
24
29
  <div class="bg-white rounded shadow-sm position-relative">
@@ -1,7 +1,7 @@
1
- from flask import Blueprint, redirect, url_for, flash, request, render_template, session, current_app
1
+ from flask import Blueprint, redirect, url_for, flash, request, render_template, session, current_app, jsonify
2
2
  from flask_login import login_required
3
3
 
4
- from ivoryos.utils.db_models import Script, db
4
+ from ivoryos.utils.db_models import Script, db, WorkflowRun, WorkflowStep
5
5
  from ivoryos.utils.utils import get_script_file, post_script_file
6
6
 
7
7
  database = Blueprint('database', __name__, template_folder='templates/database')
@@ -188,3 +188,44 @@ def save_as():
188
188
  else:
189
189
  flash("Script name is already exist in database")
190
190
  return redirect(url_for("design.experiment_builder"))
191
+
192
+
193
+ @database.route('/workflow_runs')
194
+ def list_workflows():
195
+ query = WorkflowRun.query
196
+ search_term = request.args.get("keyword", None)
197
+ if search_term:
198
+ query = query.filter(WorkflowRun.name.like(f'%{search_term}%'))
199
+ page = request.args.get('page', default=1, type=int)
200
+ per_page = 10
201
+
202
+ workflows = query.paginate(page=page, per_page=per_page, error_out=False)
203
+ return render_template('workflow_run_database.html', workflows=workflows)
204
+
205
+
206
+ @database.route('/workflow_steps/<int:workflow_id>')
207
+ def get_workflow_steps(workflow_id):
208
+ steps = WorkflowStep.query.filter_by(workflow_id=workflow_id).all()
209
+ steps_data = [step.as_dict() for step in steps]
210
+ return jsonify({'steps': steps_data})
211
+
212
+
213
+ @database.route("/delete_workflow_data/<workflow_id>")
214
+ @login_required
215
+ def delete_workflow_data(workflow_id: str):
216
+ """
217
+ .. :quickref: Database; delete experiment data from database
218
+
219
+ delete workflow data from database
220
+
221
+ .. http:get:: /delete_workflow_data/<workflow_id>
222
+
223
+ :param workflow_id: workflow id
224
+ :type workflow_id: str
225
+ :status 302: redirect to :http:get:`/ivoryos/workflow_runs/`
226
+
227
+ """
228
+ run = WorkflowRun.query.get(workflow_id)
229
+ db.session.delete(run)
230
+ db.session.commit()
231
+ return redirect(url_for('database.list_workflows'))
@@ -30,7 +30,7 @@
30
30
  <th scope="col">Time created</th>
31
31
  <th scope="col">Last modified</th>
32
32
  <th scope="col">Author</th>
33
- <th scope="col">Registered</th>
33
+ {# <th scope="col">Registered</th>#}
34
34
  <th scope="col"></th>
35
35
  </tr>
36
36
  </thead>
@@ -43,13 +43,13 @@
43
43
  <td>{{ workflow.time_created }}</td>
44
44
  <td>{{ workflow.last_modified }}</td>
45
45
  <td>{{ workflow.author }}</td>
46
- <td>{{ workflow.registered }}</td>
46
+ {# <td>{{ workflow.registered }}</td>#}
47
47
  <td>
48
48
  {#not workflow.status == "finalized" or#}
49
49
  {% if session['user'] == 'admin' or session['user'] == workflow.author %}
50
50
  <a href="{{ url_for('database.delete_workflow', workflow_name=workflow.name) }}">delete</a>
51
51
  {% else %}
52
- <a class="disabled-link" href="{{ url_for('database.delete_workflow', workflow_name=workflow.name) }}">delete</a>
52
+ <a class="disabled-link">delete</a>
53
53
  {% endif %}
54
54
  <td>
55
55
  </tr>
@@ -0,0 +1,81 @@
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}IvoryOS | Design Database{% endblock %}
4
+ {% block body %}
5
+
6
+ <table class="table table-hover" id="workflowResultLibrary">
7
+ <thead>
8
+ <tr>
9
+ <th scope="col">Workflow name</th>
10
+ <th scope="col">Start time</th>
11
+ <th scope="col">End time</th>
12
+ <th scope="col">Data</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ {% for workflow in workflows %}
17
+ <tr>
18
+ <td><a href="{{ url_for('database.get_workflow_steps', workflow_id=workflow.id) }}">{{ workflow.name }}</a></td>
19
+ <td>{{ workflow.start_time.strftime("%Y-%m-%d %H:%M:%S") if workflow.start_time else '' }}</td>
20
+ <td>{{ workflow.end_time.strftime("%Y-%m-%d %H:%M:%S") if workflow.end_time else '' }}</td>
21
+
22
+ <td>
23
+ {% if workflow.data_path %}
24
+ <a href="{{ url_for('design.download_results', filename=workflow.data_path) }}">{{ workflow.data_path }}</a>
25
+ {% endif %}
26
+ </td>
27
+ <td>
28
+ {% if session['user'] == 'admin' or session['user'] == workflow.author %}
29
+ <a href="{{ url_for('database.delete_workflow_data', workflow_id=workflow.id) }}">delete</a>
30
+ {% else %}
31
+ <a class="disabled-link">delete</a>
32
+ {% endif %}
33
+ </td>
34
+ </tr>
35
+ {% endfor %}
36
+ </tbody>
37
+ </table>
38
+
39
+ {# paging#}
40
+ <div class="pagination justify-content-center">
41
+ <div class="page-item {{ 'disabled' if not workflows.has_prev else '' }}">
42
+ <a class="page-link" href="{{ url_for('database.list_workflows', page=workflows.prev_num) }}">Previous</a>
43
+ </div>
44
+ {% for num in workflows.iter_pages() %}
45
+ <div class="page-item">
46
+ <a class="page-link {{ 'active' if num == workflows.page else '' }}" href="{{ url_for('database.list_workflows', page=num) }}">{{ num }}</a>
47
+ </div>
48
+ {% endfor %}
49
+ <div class="page-item {{ 'disabled' if not workflows.has_next else '' }}">
50
+ <a class="page-link" href="{{ url_for('database.list_workflows', page=workflows.next_num) }}">Next</a>
51
+ </div>
52
+ </div>
53
+
54
+ <div id="steps-container"></div>
55
+
56
+ <script>
57
+ function showSteps(workflowId) {
58
+ fetch(`/workflow_steps/${workflowId}`)
59
+ .then(response => response.json())
60
+ .then(data => {
61
+ const container = document.getElementById('steps-container');
62
+ container.innerHTML = ''; // Clear previous content
63
+ const stepsList = document.createElement('ul');
64
+
65
+ data.steps.forEach(step => {
66
+ const li = document.createElement('li');
67
+ li.innerHTML = `
68
+ <strong>Step: </strong> ${step.method_name} <br>
69
+ <strong>Start Time:</strong> ${step.start_time} <br>
70
+ <strong>End Time:</strong> ${step.end_time} <br>
71
+ <strong>Human Intervention:</strong> ${step.run_error ? 'Yes' : 'No'}
72
+ `;
73
+ stepsList.appendChild(li);
74
+ });
75
+
76
+ container.appendChild(stepsList);
77
+ });
78
+ }
79
+ </script>
80
+
81
+ {% endblock %}
@@ -11,6 +11,7 @@ from flask_socketio import SocketIO
11
11
  from werkzeug.utils import secure_filename
12
12
 
13
13
  from ivoryos.utils import utils
14
+ from ivoryos.utils.client_proxy import create_function, export_to_python
14
15
  from ivoryos.utils.global_config import GlobalConfig
15
16
  from ivoryos.utils.form import create_builtin_form, create_action_button, format_name, create_form_from_pseudo, \
16
17
  create_form_from_action, create_all_builtin_forms
@@ -360,7 +361,7 @@ def experiment_run():
360
361
  run_name = script.validate_function_name(run_name)
361
362
  runner.run_script(script=script, run_name=run_name, config=config, bo_args=bo_args,
362
363
  logger=g.logger, socketio=g.socketio, repeat_count=repeat,
363
- output_path=datapath
364
+ output_path=datapath, current_app=current_app._get_current_object()
364
365
  )
365
366
  if utils.check_config_duplicate(config):
366
367
  flash(f"WARNING: Duplicate in config entries.")
@@ -525,7 +526,20 @@ def download(filetype):
525
526
  outfile.write(json_object)
526
527
  elif filetype == "python":
527
528
  filepath = os.path.join(current_app.config["SCRIPT_FOLDER"], f"{run_name}.py")
528
-
529
+ elif filetype == "proxy":
530
+ snapshot = global_config.deck_snapshot.copy()
531
+ class_definitions = {}
532
+ # Iterate through each instrument in the snapshot
533
+ for instrument_key, instrument_data in snapshot.items():
534
+ # Iterate through each function associated with the current instrument
535
+ for function_key, function_data in instrument_data.items():
536
+ # Convert the function signature to a string representation
537
+ function_data['signature'] = str(function_data['signature'])
538
+ class_name = instrument_key.split('.')[-1] # Extracting the class name from the path
539
+ class_definitions[class_name.capitalize()] = create_function(request.url_root, class_name, instrument_data)
540
+ # Export the generated class definitions to a .py script
541
+ export_to_python(class_definitions, current_app.config["OUTPUT_FOLDER"])
542
+ filepath = os.path.join(current_app.config["OUTPUT_FOLDER"], "generated_proxy.py")
529
543
  return send_file(os.path.abspath(filepath), as_attachment=True)
530
544
 
531
545