micantis 1.2.4__tar.gz → 1.2.5__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
1
  Metadata-Version: 2.4
2
2
  Name: micantis
3
- Version: 1.2.4
3
+ Version: 1.2.5
4
4
  Summary: Package to simplify Micantis API usage
5
5
  Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
6
  License-Expression: MIT
@@ -1013,14 +1013,16 @@ for group in dupes:
1013
1013
  Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python script execution. Called from within a Python script running in the Micantis environment.
1014
1014
 
1015
1015
  #### Parameters
1016
- - `execution_id`: **str (required)**
1017
- The execution ID (GUID) of the running or completed execution
1016
+ - `execution_id`: **str (optional inside cloud executions)**
1017
+ The execution ID (GUID) of the running or completed execution. Defaults to the `WORKBOOK_EXECUTION_ID` environment variable, which cloud executions always set; required when running elsewhere. A non-GUID first positional argument is treated as `file_path`
1018
1018
  - `file_path`: **str (required)**
1019
1019
  Local path to the file to upload. Maximum size: 50 MB
1020
1020
  - `title`: **str (required)**
1021
1021
  Human-readable title for the artifact
1022
1022
  - `filename`: **str (optional)**
1023
1023
  Override the filename stored on the server. If not provided, derived from the title and file extension
1024
+ - `mime`: **str (optional)**
1025
+ Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
1024
1026
 
1025
1027
  #### Returns
1026
1028
  - Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
@@ -1028,22 +1030,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
1028
1030
  #### 📘 Examples
1029
1031
 
1030
1032
  ```python
1031
- # Upload a plot generated during a Python execution
1032
- result = api.upload_execution_artifact(
1033
- execution_id="your-execution-guid",
1034
- file_path="voltage_plot.png",
1035
- title="Voltage vs Time"
1036
- )
1033
+ # Inside a cloud execution execution_id is resolved from the environment
1034
+ result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
1037
1035
  print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
1038
1036
  ```
1039
1037
 
1040
1038
  ```python
1041
- # Upload a CSV results file with a custom filename
1039
+ # Explicit execution ID with a custom filename and content type
1042
1040
  result = api.upload_execution_artifact(
1043
1041
  execution_id="your-execution-guid",
1044
1042
  file_path="results.csv",
1045
1043
  title="Cycle Summary Results",
1046
- filename="cycle_summary_2025.csv"
1044
+ filename="cycle_summary_2025.csv",
1045
+ mime="text/csv"
1047
1046
  )
1048
1047
  ```
1049
1048
 
@@ -993,14 +993,16 @@ for group in dupes:
993
993
  Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python script execution. Called from within a Python script running in the Micantis environment.
994
994
 
995
995
  #### Parameters
996
- - `execution_id`: **str (required)**
997
- The execution ID (GUID) of the running or completed execution
996
+ - `execution_id`: **str (optional inside cloud executions)**
997
+ The execution ID (GUID) of the running or completed execution. Defaults to the `WORKBOOK_EXECUTION_ID` environment variable, which cloud executions always set; required when running elsewhere. A non-GUID first positional argument is treated as `file_path`
998
998
  - `file_path`: **str (required)**
999
999
  Local path to the file to upload. Maximum size: 50 MB
1000
1000
  - `title`: **str (required)**
1001
1001
  Human-readable title for the artifact
1002
1002
  - `filename`: **str (optional)**
1003
1003
  Override the filename stored on the server. If not provided, derived from the title and file extension
1004
+ - `mime`: **str (optional)**
1005
+ Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
1004
1006
 
1005
1007
  #### Returns
1006
1008
  - Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
@@ -1008,22 +1010,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
1008
1010
  #### 📘 Examples
1009
1011
 
1010
1012
  ```python
1011
- # Upload a plot generated during a Python execution
1012
- result = api.upload_execution_artifact(
1013
- execution_id="your-execution-guid",
1014
- file_path="voltage_plot.png",
1015
- title="Voltage vs Time"
1016
- )
1013
+ # Inside a cloud execution execution_id is resolved from the environment
1014
+ result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
1017
1015
  print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
1018
1016
  ```
1019
1017
 
1020
1018
  ```python
1021
- # Upload a CSV results file with a custom filename
1019
+ # Explicit execution ID with a custom filename and content type
1022
1020
  result = api.upload_execution_artifact(
1023
1021
  execution_id="your-execution-guid",
1024
1022
  file_path="results.csv",
1025
1023
  title="Cycle Summary Results",
1026
- filename="cycle_summary_2025.csv"
1024
+ filename="cycle_summary_2025.csv",
1025
+ mime="text/csv"
1027
1026
  )
1028
1027
  ```
1029
1028
 
@@ -2,11 +2,15 @@ import requests
2
2
  import pandas as pd
3
3
  import io
4
4
  import json
5
+ import mimetypes
5
6
  import os
6
7
  import re
7
8
  import time
9
+ import tempfile
10
+ import uuid
8
11
  from datetime import datetime, timedelta, timezone
9
12
  from typing import Optional
13
+ from urllib.parse import quote
10
14
  from msal import PublicClientApplication
11
15
  from azure.identity import (
12
16
  ManagedIdentityCredential,
@@ -43,6 +47,26 @@ def _apply_field_overrides(action, voltage, current, charge_capacity,
43
47
  action[key] = val
44
48
 
45
49
 
50
+ def _is_guid(value) -> bool:
51
+ """True if value parses as a GUID — the format of execution IDs."""
52
+ try:
53
+ uuid.UUID(str(value))
54
+ return True
55
+ except ValueError:
56
+ return False
57
+
58
+
59
+ # Content types that must not depend on the host OS mime registry
60
+ # (Windows maps .csv to application/vnd.ms-excel, stored as an opaque artifact).
61
+ _MIME_OVERRIDES = {
62
+ ".csv": "text/csv",
63
+ ".json": "application/json",
64
+ ".txt": "text/plain",
65
+ ".md": "text/markdown",
66
+ ".svg": "image/svg+xml",
67
+ }
68
+
69
+
46
70
  class BatchUpdateResult:
47
71
  """
48
72
  Result of a batch metadata update call (`write_cell_metadata` / `write_data_metadata`).
@@ -2794,21 +2818,34 @@ class MicantisAPI:
2794
2818
  raise self._format_request_error(e)
2795
2819
 
2796
2820
  def upload_execution_artifact(self,
2797
- execution_id: str,
2798
- file_path: str,
2799
- title: str,
2821
+ execution_id: str = None,
2822
+ file_path: str = None,
2823
+ title: str = None,
2800
2824
  filename: str = None,
2801
- timeout: int = 120) -> dict:
2825
+ timeout: int = 120,
2826
+ mime: str = None) -> dict:
2802
2827
  """
2803
2828
  Upload an artifact (output file) produced by a running Python execution.
2804
2829
 
2830
+ Inside a cloud execution, execution_id can be omitted — it is resolved
2831
+ from the WORKBOOK_EXECUTION_ID environment variable, which the runner
2832
+ always sets. The natural call shape there is::
2833
+
2834
+ api.upload_execution_artifact('/tmp/plot.png', title='My Plot')
2835
+
2836
+ A first positional argument that is not a GUID is treated as file_path
2837
+ (when file_path is not also given), so the line above works even though
2838
+ the first parameter is execution_id.
2839
+
2805
2840
  Automatically reauthenticates if the session is expired or missing.
2806
2841
  Prints an error message and returns None if the request ultimately fails.
2807
2842
 
2808
2843
  Parameters
2809
2844
  ----------
2810
- execution_id : str
2845
+ execution_id : str, optional
2811
2846
  The execution ID (GUID) of the running or completed Python execution.
2847
+ Defaults to the WORKBOOK_EXECUTION_ID environment variable (always
2848
+ set inside cloud executions). Required when running outside one.
2812
2849
  file_path : str
2813
2850
  Local path to the file to upload (PNG, CSV, XLSX, JSON, PDF, etc.).
2814
2851
  Maximum file size: 50 MB.
@@ -2819,6 +2856,10 @@ class MicantisAPI:
2819
2856
  from the title and file extension.
2820
2857
  timeout : int, optional
2821
2858
  Request timeout in seconds. Default: 120.
2859
+ mime : str, optional
2860
+ Content type stored for the artifact (e.g. 'image/png'). If not
2861
+ provided, guessed from the file_path extension, falling back to
2862
+ 'application/octet-stream'.
2822
2863
 
2823
2864
  Returns
2824
2865
  -------
@@ -2834,6 +2875,7 @@ class MicantisAPI:
2834
2875
  title,
2835
2876
  filename,
2836
2877
  timeout,
2878
+ mime,
2837
2879
  )
2838
2880
  except Exception as e:
2839
2881
  print(f"⚠️ upload_execution_artifact failed: {e}")
@@ -2844,28 +2886,61 @@ class MicantisAPI:
2844
2886
  file_path: str,
2845
2887
  title: str,
2846
2888
  filename: str,
2847
- timeout: int) -> dict:
2889
+ timeout: int,
2890
+ mime: str) -> dict:
2848
2891
  """
2849
2892
  Internal helper for upload_execution_artifact. Does not handle retries or printing.
2850
2893
 
2851
2894
  Raises
2852
2895
  ------
2853
2896
  RuntimeError
2854
- If the request fails or authentication is missing.
2897
+ If arguments are missing or invalid, the request fails, or
2898
+ authentication is missing.
2855
2899
  """
2856
2900
  if not self.headers:
2857
2901
  raise RuntimeError("Authentication required. Call authenticate() first.")
2858
2902
 
2859
2903
  self._refresh_token_if_needed()
2860
2904
 
2905
+ # Forgive the natural call shape api.upload_execution_artifact('/tmp/plot.png', title=...):
2906
+ # a non-GUID first argument is the file path, and the execution ID comes from the environment.
2907
+ if execution_id is not None and not _is_guid(execution_id):
2908
+ if file_path is None:
2909
+ file_path = execution_id
2910
+ execution_id = None
2911
+ else:
2912
+ raise RuntimeError(
2913
+ f"execution_id '{execution_id}' is not an execution GUID. Inside a cloud "
2914
+ "execution you can omit execution_id entirely — it is read from the "
2915
+ "WORKBOOK_EXECUTION_ID environment variable automatically."
2916
+ )
2917
+
2918
+ if execution_id is None:
2919
+ execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
2920
+ if not execution_id:
2921
+ raise RuntimeError(
2922
+ "No execution_id provided and WORKBOOK_EXECUTION_ID is not set. Inside a "
2923
+ "cloud execution the variable is set automatically; outside one, pass "
2924
+ "execution_id explicitly."
2925
+ )
2926
+
2927
+ if not file_path:
2928
+ raise RuntimeError("file_path is required.")
2929
+ if not title:
2930
+ raise RuntimeError("title is required.")
2931
+
2861
2932
  url = f"{self.service_url}/publicapi/v1/python/executions/{execution_id}/artifacts"
2862
2933
 
2934
+ ext = os.path.splitext(file_path)[1].lower()
2935
+ content_type = (mime or _MIME_OVERRIDES.get(ext)
2936
+ or mimetypes.guess_type(file_path)[0] or "application/octet-stream")
2937
+
2863
2938
  try:
2864
2939
  with open(file_path, 'rb') as f:
2865
2940
  form_data = {'title': (None, title)}
2866
2941
  if filename is not None:
2867
2942
  form_data['filename'] = (None, filename)
2868
- form_data['file'] = (os.path.basename(file_path), f)
2943
+ form_data['file'] = (os.path.basename(file_path), f, content_type)
2869
2944
 
2870
2945
  # Exclude Content-Type so requests sets the multipart boundary correctly
2871
2946
  headers = {k: v for k, v in self.headers.items() if k.lower() != 'content-type'}
@@ -2882,6 +2957,114 @@ class MicantisAPI:
2882
2957
  except requests.exceptions.RequestException as e:
2883
2958
  raise self._format_request_error(e)
2884
2959
 
2960
+ def list_attachments(self, timeout: int = 30) -> list:
2961
+ """
2962
+ List the files attached to the Moose conversation that launched this script.
2963
+
2964
+ Only works inside a Moose-triggered cloud execution (uses WORKBOOK_EXECUTION_ID).
2965
+ Automatically reauthenticates if the session is expired or missing. Prints an error
2966
+ and returns None on failure.
2967
+
2968
+ Returns
2969
+ -------
2970
+ list of dict or None
2971
+ One entry per attachment: {"name", "contentType", "sizeBytes"}.
2972
+ """
2973
+ try:
2974
+ return self._retry_on_auth_failure(self._list_attachments, timeout)
2975
+ except Exception as e:
2976
+ print(f"⚠️ list_attachments failed: {e}")
2977
+ return None
2978
+
2979
+ def _list_attachments(self, timeout: int) -> list:
2980
+ if not self.headers:
2981
+ raise RuntimeError("Authentication required. Call authenticate() first.")
2982
+ self._refresh_token_if_needed()
2983
+
2984
+ execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
2985
+ if not execution_id:
2986
+ raise RuntimeError(
2987
+ "list_attachments is only available inside a cloud execution (WORKBOOK_EXECUTION_ID is not set)."
2988
+ )
2989
+
2990
+ try:
2991
+ r = self.session.get(
2992
+ f"{self.service_url}/publicapi/v1/python/executions/{execution_id}/attachments",
2993
+ headers=self.headers,
2994
+ timeout=timeout,
2995
+ )
2996
+ r.raise_for_status()
2997
+ return r.json()
2998
+ except requests.exceptions.RequestException as e:
2999
+ raise self._format_request_error(e)
3000
+
3001
+ def download_attachment(self, name: str, output_path: str = None,
3002
+ return_type: str = "bytes", timeout: int = 120):
3003
+ """
3004
+ Download a file attached to the Moose conversation that launched this script, by name,
3005
+ WITHOUT inlining its bytes into your script source. Use this for any conversation
3006
+ attachment too large to paste in — inlining data into the script is bounded by the
3007
+ per-turn output budget (a few KB), but this streams the file into the container.
3008
+
3009
+ Only works inside a Moose-triggered cloud execution (uses WORKBOOK_EXECUTION_ID). Call
3010
+ list_attachments() first to get exact names. Automatically reauthenticates if the
3011
+ session is expired or missing. Prints an error and returns None on failure.
3012
+
3013
+ Parameters
3014
+ ----------
3015
+ name : str
3016
+ Exact attachment filename (from list_attachments()).
3017
+ output_path : str, optional
3018
+ Where to save the file when return_type='path'. Defaults to a temp file.
3019
+ return_type : str, optional
3020
+ 'bytes' (default) returns the raw bytes; 'path' writes the file to disk and returns
3021
+ the path (best for large files); 'text' returns a UTF-8-decoded str.
3022
+ timeout : int, optional
3023
+ Request timeout in seconds. Default: 120.
3024
+
3025
+ Returns
3026
+ -------
3027
+ bytes | str | None
3028
+ The attachment content per return_type, or None on failure.
3029
+ """
3030
+ try:
3031
+ return self._retry_on_auth_failure(
3032
+ self._download_attachment, name, output_path, return_type, timeout)
3033
+ except Exception as e:
3034
+ print(f"⚠️ download_attachment failed: {e}")
3035
+ return None
3036
+
3037
+ def _download_attachment(self, name: str, output_path: str, return_type: str, timeout: int):
3038
+ if not self.headers:
3039
+ raise RuntimeError("Authentication required. Call authenticate() first.")
3040
+ self._refresh_token_if_needed()
3041
+
3042
+ execution_id = os.environ.get("WORKBOOK_EXECUTION_ID")
3043
+ if not execution_id:
3044
+ raise RuntimeError(
3045
+ "download_attachment is only available inside a cloud execution (WORKBOOK_EXECUTION_ID is not set)."
3046
+ )
3047
+
3048
+ url = (f"{self.service_url}/publicapi/v1/python/executions/{execution_id}"
3049
+ f"/attachments/{quote(name)}")
3050
+
3051
+ try:
3052
+ with self.session.get(url, headers=self.headers, timeout=timeout, stream=True) as r:
3053
+ r.raise_for_status()
3054
+ if return_type == "path":
3055
+ path = output_path or os.path.join(tempfile.gettempdir(), os.path.basename(name))
3056
+ with open(path, "wb") as f:
3057
+ for chunk in r.iter_content(chunk_size=1024 * 1024):
3058
+ if chunk:
3059
+ f.write(chunk)
3060
+ return path
3061
+ content = r.content
3062
+ if return_type == "text":
3063
+ return content.decode("utf-8", errors="replace")
3064
+ return content
3065
+ except requests.exceptions.RequestException as e:
3066
+ raise self._format_request_error(e)
3067
+
2885
3068
  # ─────────────────────────────────────────
2886
3069
  # Job management (async operation polling)
2887
3070
  # ─────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: micantis
3
- Version: 1.2.4
3
+ Version: 1.2.5
4
4
  Summary: Package to simplify Micantis API usage
5
5
  Author-email: Mykela DeLuca <mykela.deluca@micantis.io>
6
6
  License-Expression: MIT
@@ -1013,14 +1013,16 @@ for group in dupes:
1013
1013
  Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python script execution. Called from within a Python script running in the Micantis environment.
1014
1014
 
1015
1015
  #### Parameters
1016
- - `execution_id`: **str (required)**
1017
- The execution ID (GUID) of the running or completed execution
1016
+ - `execution_id`: **str (optional inside cloud executions)**
1017
+ The execution ID (GUID) of the running or completed execution. Defaults to the `WORKBOOK_EXECUTION_ID` environment variable, which cloud executions always set; required when running elsewhere. A non-GUID first positional argument is treated as `file_path`
1018
1018
  - `file_path`: **str (required)**
1019
1019
  Local path to the file to upload. Maximum size: 50 MB
1020
1020
  - `title`: **str (required)**
1021
1021
  Human-readable title for the artifact
1022
1022
  - `filename`: **str (optional)**
1023
1023
  Override the filename stored on the server. If not provided, derived from the title and file extension
1024
+ - `mime`: **str (optional)**
1025
+ Content type stored for the artifact (e.g. `image/png`). If not provided, guessed from the file extension, falling back to `application/octet-stream`
1024
1026
 
1025
1027
  #### Returns
1026
1028
  - Dictionary with `'name'`, `'title'`, `'contentType'`, and `'sizeBytes'`
@@ -1028,22 +1030,19 @@ Attach an output file (PNG, CSV, XLSX, etc.) to a running or completed Python sc
1028
1030
  #### 📘 Examples
1029
1031
 
1030
1032
  ```python
1031
- # Upload a plot generated during a Python execution
1032
- result = api.upload_execution_artifact(
1033
- execution_id="your-execution-guid",
1034
- file_path="voltage_plot.png",
1035
- title="Voltage vs Time"
1036
- )
1033
+ # Inside a cloud execution execution_id is resolved from the environment
1034
+ result = api.upload_execution_artifact("voltage_plot.png", title="Voltage vs Time")
1037
1035
  print(f"Uploaded: {result['name']} ({result['sizeBytes']:,} bytes)")
1038
1036
  ```
1039
1037
 
1040
1038
  ```python
1041
- # Upload a CSV results file with a custom filename
1039
+ # Explicit execution ID with a custom filename and content type
1042
1040
  result = api.upload_execution_artifact(
1043
1041
  execution_id="your-execution-guid",
1044
1042
  file_path="results.csv",
1045
1043
  title="Cycle Summary Results",
1046
- filename="cycle_summary_2025.csv"
1044
+ filename="cycle_summary_2025.csv",
1045
+ mime="text/csv"
1047
1046
  )
1048
1047
  ```
1049
1048
 
@@ -9,4 +9,5 @@ micantis.egg-info/SOURCES.txt
9
9
  micantis.egg-info/dependency_links.txt
10
10
  micantis.egg-info/requires.txt
11
11
  micantis.egg-info/top_level.txt
12
- tests/test_batch_update_result.py
12
+ tests/test_batch_update_result.py
13
+ tests/test_upload_execution_artifact.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "micantis"
3
- version = "1.2.4"
3
+ version = "1.2.5"
4
4
  description = "Package to simplify Micantis API usage"
5
5
  authors = [
6
6
  { name = "Mykela DeLuca", email = "mykela.deluca@micantis.io" }
@@ -0,0 +1,110 @@
1
+ """
2
+ Unit tests for upload_execution_artifact argument resolution and multipart content type.
3
+
4
+ Mocks the HTTP layer so it can run anywhere without a live server. Exercises the
5
+ four supported call shapes:
6
+ - Path-only positional + title + mime → execution_id resolved from WORKBOOK_EXECUTION_ID
7
+ - Classic positional (guid, path, title) → unchanged, content type guessed from extension
8
+ - Full keyword form → unchanged
9
+ - No execution_id anywhere and no env var → clear printed error, returns None
10
+
11
+ Run: pytest
12
+ """
13
+ from .test_batch_update_result import _make_api, _mock_response
14
+
15
+ EXEC_ID = "11111111-2222-3333-4444-555555555555"
16
+ OTHER_EXEC_ID = "99999999-8888-7777-6666-555555555555"
17
+
18
+ RESULT_BODY = {
19
+ "name": "plot.png",
20
+ "title": "My Plot",
21
+ "contentType": "image/png",
22
+ "sizeBytes": 9,
23
+ }
24
+
25
+
26
+ def test_path_first_positional_resolves_execution_id_from_env(monkeypatch, tmp_path):
27
+ # The natural cloud call shape: file path first, no execution_id anywhere.
28
+ api = _make_api()
29
+ api.session.post.return_value = _mock_response(200, RESULT_BODY)
30
+ monkeypatch.setenv("WORKBOOK_EXECUTION_ID", EXEC_ID)
31
+
32
+ png = tmp_path / "plot.png"
33
+ png.write_bytes(b"\x89PNG fake")
34
+
35
+ result = api.upload_execution_artifact(str(png), title="My Plot", mime="image/png")
36
+
37
+ assert result == RESULT_BODY
38
+ url = api.session.post.call_args.args[0]
39
+ assert url == f"https://fake.test/publicapi/v1/python/executions/{EXEC_ID}/artifacts"
40
+ name, fobj, content_type = api.session.post.call_args.kwargs["files"]["file"]
41
+ assert name == "plot.png"
42
+ assert content_type == "image/png"
43
+ print("PASS: path-first positional resolves WORKBOOK_EXECUTION_ID and sends mime")
44
+
45
+
46
+ def test_classic_positional_shape_unchanged(monkeypatch, tmp_path):
47
+ # (execution_id, file_path, title) keeps working; explicit GUID wins over the env var,
48
+ # and the part content type is guessed from the file extension when mime is omitted.
49
+ api = _make_api()
50
+ api.session.post.return_value = _mock_response(200, RESULT_BODY)
51
+ monkeypatch.setenv("WORKBOOK_EXECUTION_ID", OTHER_EXEC_ID)
52
+
53
+ png = tmp_path / "voltage_profile.png"
54
+ png.write_bytes(b"\x89PNG fake")
55
+
56
+ result = api.upload_execution_artifact(EXEC_ID, str(png), "Voltage Profile")
57
+
58
+ assert result == RESULT_BODY
59
+ url = api.session.post.call_args.args[0]
60
+ assert url == f"https://fake.test/publicapi/v1/python/executions/{EXEC_ID}/artifacts"
61
+ name, fobj, content_type = api.session.post.call_args.kwargs["files"]["file"]
62
+ assert name == "voltage_profile.png"
63
+ assert content_type == "image/png"
64
+ print("PASS: classic positional (guid, path, title) unchanged; mime guessed from extension")
65
+
66
+
67
+ def test_keyword_shape_unchanged(monkeypatch, tmp_path):
68
+ api = _make_api()
69
+ api.session.post.return_value = _mock_response(200, RESULT_BODY)
70
+ monkeypatch.delenv("WORKBOOK_EXECUTION_ID", raising=False)
71
+
72
+ csv = tmp_path / "results.csv"
73
+ csv.write_text("a,b\n1,2\n")
74
+
75
+ result = api.upload_execution_artifact(
76
+ execution_id=EXEC_ID,
77
+ file_path=str(csv),
78
+ title="Analysis Results",
79
+ filename="cycle_summary.csv",
80
+ )
81
+
82
+ assert result == RESULT_BODY
83
+ url = api.session.post.call_args.args[0]
84
+ assert url == f"https://fake.test/publicapi/v1/python/executions/{EXEC_ID}/artifacts"
85
+ files = api.session.post.call_args.kwargs["files"]
86
+ assert files["filename"] == (None, "cycle_summary.csv")
87
+ name, fobj, content_type = files["file"]
88
+ assert name == "results.csv"
89
+ # Pinned by _MIME_OVERRIDES, independent of the host OS mime registry.
90
+ assert content_type == "text/csv"
91
+ print("PASS: full keyword form unchanged; filename part still sent")
92
+
93
+
94
+ def test_missing_execution_id_and_env_var_reports_clear_error(monkeypatch, tmp_path, capsys):
95
+ # Outside a cloud execution with no explicit execution_id: P-03 convention —
96
+ # print the real error (naming the env var) and return None, no HTTP call.
97
+ api = _make_api()
98
+ monkeypatch.delenv("WORKBOOK_EXECUTION_ID", raising=False)
99
+
100
+ csv = tmp_path / "out.csv"
101
+ csv.write_text("a,b\n")
102
+
103
+ result = api.upload_execution_artifact(file_path=str(csv), title="Out")
104
+
105
+ assert result is None
106
+ out = capsys.readouterr().out
107
+ assert "upload_execution_artifact failed" in out
108
+ assert "WORKBOOK_EXECUTION_ID" in out
109
+ api.session.post.assert_not_called()
110
+ print("PASS: missing execution_id + env var → clear error naming WORKBOOK_EXECUTION_ID")
File without changes
File without changes
File without changes
File without changes