atomscale 0.8.2__tar.gz → 0.8.3__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.
Files changed (115) hide show
  1. {atomscale-0.8.2 → atomscale-0.8.3}/PKG-INFO +1 -1
  2. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/rheed_stream.pyi +1 -0
  3. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/src/initialize.rs +76 -3
  4. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/src/lib.rs +34 -7
  5. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale.egg-info/PKG-INFO +1 -1
  6. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale.egg-info/SOURCES.txt +2 -0
  7. atomscale-0.8.3/tests/_mock_http_server.py +122 -0
  8. atomscale-0.8.3/tests/test_rheed_stream.py +279 -0
  9. {atomscale-0.8.2 → atomscale-0.8.3}/.github/workflows/release.yml +0 -0
  10. {atomscale-0.8.2 → atomscale-0.8.3}/.github/workflows/testing.yml +0 -0
  11. {atomscale-0.8.2 → atomscale-0.8.3}/.github/workflows/upgrade_dependencies.yml +0 -0
  12. {atomscale-0.8.2 → atomscale-0.8.3}/.gitignore +0 -0
  13. {atomscale-0.8.2 → atomscale-0.8.3}/.pre-commit-config.yaml +0 -0
  14. {atomscale-0.8.2 → atomscale-0.8.3}/CHANGELOG.md +0 -0
  15. {atomscale-0.8.2 → atomscale-0.8.3}/LICENSE +0 -0
  16. {atomscale-0.8.2 → atomscale-0.8.3}/MANIFEST.in +0 -0
  17. {atomscale-0.8.2 → atomscale-0.8.3}/README.md +0 -0
  18. {atomscale-0.8.2 → atomscale-0.8.3}/atomicds-shim-dist/pyproject.toml +0 -0
  19. {atomscale-0.8.2 → atomscale-0.8.3}/docs/Makefile +0 -0
  20. {atomscale-0.8.2 → atomscale-0.8.3}/docs/_templates/custom-class-template.rst +0 -0
  21. {atomscale-0.8.2 → atomscale-0.8.3}/docs/_templates/custom-module-template.rst +0 -0
  22. {atomscale-0.8.2 → atomscale-0.8.3}/docs/conf.py +0 -0
  23. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/index.rst +0 -0
  24. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/inspect-results.rst +0 -0
  25. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/poll-timeseries.rst +0 -0
  26. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/quickstart.rst +0 -0
  27. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/search-data.rst +0 -0
  28. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/stream-rheed.rst +0 -0
  29. {atomscale-0.8.2 → atomscale-0.8.3}/docs/guides/upload-data.rst +0 -0
  30. {atomscale-0.8.2 → atomscale-0.8.3}/docs/index.rst +0 -0
  31. {atomscale-0.8.2 → atomscale-0.8.3}/docs/make.bat +0 -0
  32. {atomscale-0.8.2 → atomscale-0.8.3}/docs/modules.rst +0 -0
  33. {atomscale-0.8.2 → atomscale-0.8.3}/examples/general_use.ipynb +0 -0
  34. {atomscale-0.8.2 → atomscale-0.8.3}/examples/rheed_streaming.ipynb +0 -0
  35. {atomscale-0.8.2 → atomscale-0.8.3}/examples/timeseries_polling.ipynb +0 -0
  36. {atomscale-0.8.2 → atomscale-0.8.3}/examples/vxwse2-placeholder/task1_films.ipynb +0 -0
  37. {atomscale-0.8.2 → atomscale-0.8.3}/examples/vxwse2-placeholder/task1_sapphire.ipynb +0 -0
  38. {atomscale-0.8.2 → atomscale-0.8.3}/examples/vxwse2-placeholder/task2_composition.ipynb +0 -0
  39. {atomscale-0.8.2 → atomscale-0.8.3}/pyproject.toml +0 -0
  40. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.10.txt +0 -0
  41. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.10_extras.txt +0 -0
  42. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.11.txt +0 -0
  43. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.11_extras.txt +0 -0
  44. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.12.txt +0 -0
  45. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.12_extras.txt +0 -0
  46. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.9.txt +0 -0
  47. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-macos-latest_py3.9_extras.txt +0 -0
  48. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.10.txt +0 -0
  49. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.10_extras.txt +0 -0
  50. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.11.txt +0 -0
  51. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.11_extras.txt +0 -0
  52. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.12.txt +0 -0
  53. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.12_extras.txt +0 -0
  54. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.9.txt +0 -0
  55. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-ubuntu-latest_py3.9_extras.txt +0 -0
  56. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.10.txt +0 -0
  57. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.10_extras.txt +0 -0
  58. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.11.txt +0 -0
  59. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.11_extras.txt +0 -0
  60. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.12.txt +0 -0
  61. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.12_extras.txt +0 -0
  62. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.9.txt +0 -0
  63. {atomscale-0.8.2 → atomscale-0.8.3}/requirements/requirements-windows-latest_py3.9_extras.txt +0 -0
  64. {atomscale-0.8.2 → atomscale-0.8.3}/setup.cfg +0 -0
  65. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomicds/__init__.py +0 -0
  66. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/__init__.py +0 -0
  67. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/client.py +0 -0
  68. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/core/__init__.py +0 -0
  69. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/core/client.py +0 -0
  70. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/core/files.py +0 -0
  71. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/core/utils.py +0 -0
  72. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/__init__.py +0 -0
  73. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/group.py +0 -0
  74. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/metrology.py +0 -0
  75. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/optical.py +0 -0
  76. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/photoluminescence.py +0 -0
  77. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/raman.py +0 -0
  78. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/rheed_image.py +0 -0
  79. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/rheed_video.py +0 -0
  80. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/similarity_trajectory.py +0 -0
  81. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/unknown.py +0 -0
  82. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/results/xps.py +0 -0
  83. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/Cargo.lock +0 -0
  84. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/Cargo.toml +0 -0
  85. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/__init__.py +0 -0
  86. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/src/upload.rs +0 -0
  87. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/streaming/src/utils.rs +0 -0
  88. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/__init__.py +0 -0
  89. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/align.py +0 -0
  90. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/metrology.py +0 -0
  91. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/optical.py +0 -0
  92. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/polling.py +0 -0
  93. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/provider.py +0 -0
  94. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/registry.py +0 -0
  95. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/rheed.py +0 -0
  96. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/sample.py +0 -0
  97. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale/timeseries/similarity.py +0 -0
  98. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale.egg-info/dependency_links.txt +0 -0
  99. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale.egg-info/requires.txt +0 -0
  100. {atomscale-0.8.2 → atomscale-0.8.3}/src/atomscale.egg-info/top_level.txt +0 -0
  101. {atomscale-0.8.2 → atomscale-0.8.3}/tests/__init__.py +0 -0
  102. {atomscale-0.8.2 → atomscale-0.8.3}/tests/conftest.py +0 -0
  103. {atomscale-0.8.2 → atomscale-0.8.3}/tests/data/test_rheed.mp4 +0 -0
  104. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_atomicds_alias.py +0 -0
  105. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_client.py +0 -0
  106. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_core.py +0 -0
  107. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_metrology.py +0 -0
  108. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_optical.py +0 -0
  109. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_photoluminescence.py +0 -0
  110. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_polling.py +0 -0
  111. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_raman.py +0 -0
  112. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_rheed_image.py +0 -0
  113. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_rheed_video.py +0 -0
  114. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_similarity_trajectory.py +0 -0
  115. {atomscale-0.8.2 → atomscale-0.8.3}/tests/test_xps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomscale
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: Python SDK for Atomscale.
5
5
  Author-email: Atomscale <info@atomscale.ai>
6
6
  License: GPL-3.0-only
@@ -25,6 +25,7 @@ class RHEEDStreamer:
25
25
  chunk_size: int,
26
26
  stream_name: str | None = None,
27
27
  physical_sample: str | None = None,
28
+ project_id: str | None = None,
28
29
  ) -> str: ...
29
30
  def run(
30
31
  self,
@@ -1,6 +1,7 @@
1
1
  use anyhow::{Context, Result};
2
2
  use reqwest::Client;
3
3
  use serde::{Deserialize, Serialize};
4
+ use serde_json::Value;
4
5
 
5
6
  #[derive(Serialize, Debug)]
6
7
  #[serde(rename_all = "snake_case")] // Ensures JSON fields are snake_case (e.g., data_id)
@@ -9,6 +10,20 @@ pub struct RHEEDStreamSettings {
9
10
  pub rotational_period: f64,
10
11
  pub rotations_per_min: f64,
11
12
  pub fps_capture_rate: f64,
13
+ #[serde(skip_serializing_if = "Option::is_none")]
14
+ pub project_id: Option<String>,
15
+ }
16
+
17
+ // Project-related structs for GET /projects/ response
18
+ #[derive(Deserialize, Debug)]
19
+ struct DataConfiguration {
20
+ api_configuration: Option<Value>,
21
+ }
22
+
23
+ #[derive(Deserialize, Debug)]
24
+ struct ProjectSummary {
25
+ id: String,
26
+ configuration: Option<DataConfiguration>,
12
27
  }
13
28
 
14
29
  /// POST request to initialize a RHEED stream
@@ -58,10 +73,10 @@ pub async fn ensure_physical_sample_link(
58
73
  api_key: &str,
59
74
  data_id: &str,
60
75
  sample_name: &str,
61
- ) -> Result<()> {
76
+ ) -> Result<String> {
62
77
  let sample_name = sample_name.trim();
63
78
  if sample_name.is_empty() {
64
- return Ok(());
79
+ anyhow::bail!("sample_name cannot be empty");
65
80
  }
66
81
 
67
82
  let list_url = format!("{base_endpoint}/physical_samples/");
@@ -102,7 +117,7 @@ pub async fn ensure_physical_sample_link(
102
117
  let link_url = format!("{base_endpoint}/data_entries/physical_sample");
103
118
  let link_body = LinkPhysicalSampleRequest {
104
119
  data_ids: vec![data_id.to_string()],
105
- physical_sample_id: sample_id,
120
+ physical_sample_id: sample_id.clone(),
106
121
  };
107
122
 
108
123
  client
@@ -115,5 +130,63 @@ pub async fn ensure_physical_sample_link(
115
130
  .error_for_status()
116
131
  .context("physical sample link returned error status")?;
117
132
 
133
+ Ok(sample_id)
134
+ }
135
+
136
+ /// Updates the project's tracking_physical_sample_id in its configuration.
137
+ /// Fetches current configuration, updates the tracking sample, and POSTs back.
138
+ pub async fn update_project_tracking_sample(
139
+ client: &Client,
140
+ base_endpoint: &str,
141
+ api_key: &str,
142
+ project_id: &str,
143
+ physical_sample_id: &str,
144
+ ) -> Result<()> {
145
+ // GET /projects/ to find the project and its current configuration
146
+ let projects_url = format!("{base_endpoint}/projects/");
147
+ let projects: Vec<ProjectSummary> = client
148
+ .get(&projects_url)
149
+ .header("X-API-KEY", api_key)
150
+ .send()
151
+ .await
152
+ .context("failed to request projects")?
153
+ .error_for_status()
154
+ .context("projects list returned error status")?
155
+ .json()
156
+ .await
157
+ .context("failed to deserialize projects list")?;
158
+
159
+ // Find the project by ID
160
+ let project = projects
161
+ .into_iter()
162
+ .find(|p| p.id == project_id)
163
+ .ok_or_else(|| anyhow::anyhow!("project with id {} not found", project_id))?;
164
+
165
+ // Build updated configuration, preserving existing fields
166
+ let mut config = match project.configuration {
167
+ Some(data_config) => data_config.api_configuration.unwrap_or_else(|| Value::Object(Default::default())),
168
+ None => Value::Object(Default::default()),
169
+ };
170
+
171
+ // Update tracking_physical_sample_id in the configuration
172
+ if let Value::Object(ref mut map) = config {
173
+ map.insert(
174
+ "tracking_physical_sample_id".to_string(),
175
+ Value::String(physical_sample_id.to_string()),
176
+ );
177
+ }
178
+
179
+ // POST /projects/{project_id}/configuration with the updated config
180
+ let config_url = format!("{base_endpoint}/projects/{project_id}/configuration");
181
+ client
182
+ .post(&config_url)
183
+ .header("X-API-KEY", api_key)
184
+ .json(&config)
185
+ .send()
186
+ .await
187
+ .context("failed to update project configuration")?
188
+ .error_for_status()
189
+ .context("project configuration update returned error status")?;
190
+
118
191
  Ok(())
119
192
  }
@@ -13,7 +13,10 @@ mod utils;
13
13
  use utils::{generic_post, init_tracing_once};
14
14
 
15
15
  mod initialize;
16
- use initialize::{ensure_physical_sample_link, post_for_initialization, RHEEDStreamSettings};
16
+ use initialize::{
17
+ ensure_physical_sample_link, post_for_initialization, update_project_tracking_sample,
18
+ RHEEDStreamSettings,
19
+ };
17
20
 
18
21
  mod upload;
19
22
  use upload::{
@@ -109,7 +112,7 @@ impl RHEEDStreamer {
109
112
  }
110
113
 
111
114
  ////Initialize stream
112
- /// initialize(self, stream_name: Optional[str] = None, fps: float, rotations_per_min: float, chunk_size: int, physical_sample: Optional[str] = None) -> str
115
+ /// initialize(self, fps: float, rotations_per_min: float, chunk_size: int, stream_name: Optional[str] = None, physical_sample: Optional[str] = None, project_id: Optional[str] = None) -> str
113
116
  ///
114
117
  /// Creates a new **remote data item** for this stream and returns its `data_id`.
115
118
  /// Also captures runtime configuration used for subsequent chunk uploads.
@@ -121,21 +124,24 @@ impl RHEEDStreamer {
121
124
  /// After streaming via `run(...)` or `push(...)`, call `finalize(data_id)` to mark the stream as complete.
122
125
  ///
123
126
  /// Args:
124
- /// stream_name (Optional[str]): Human-readable name shown in the platform. If `None` or an empty string,
125
- /// a default like `"RHEED Stream @ 1:23PM"` is used.
126
127
  /// fps (float): Capture rate in frames per second.
127
128
  /// rotations_per_min (float): Wafer/crystal rotations per minute; use `0.0` for stationary operation.
128
129
  /// chunk_size (int): The **intended** number of frames per chunk you will send with `run(...)` or `push(...)`.
130
+ /// stream_name (Optional[str]): Human-readable name shown in the platform. If `None` or an empty string,
131
+ /// a default like `"RHEED Stream @ 1:23PM"` is used.
129
132
  /// physical_sample (Optional[str]): Name of a physical sample to associate with the data item; matched case-insensitively or created if missing.
133
+ /// project_id (Optional[str]): UUID of a project to associate with the stream. When provided along with
134
+ /// `physical_sample`, the project's `tracking_physical_sample_id` configuration is automatically updated
135
+ /// to link the physical sample to the project for growth monitoring.
130
136
  ///
131
137
  /// Returns:
132
138
  /// str: The created `data_id` for this stream.
133
139
  ///
134
140
  /// Raises:
135
141
  /// RuntimeError: If the initialization POST fails.
136
- #[pyo3(signature = (fps, rotations_per_min, chunk_size, stream_name=None, physical_sample=None))]
142
+ #[pyo3(signature = (fps, rotations_per_min, chunk_size, stream_name=None, physical_sample=None, project_id=None))]
137
143
  #[pyo3(
138
- text_signature = "(fps, rotations_per_min, chunk_size, stream_name=None, physical_sample=None)"
144
+ text_signature = "(fps, rotations_per_min, chunk_size, stream_name=None, physical_sample=None, project_id=None)"
139
145
  )]
140
146
  fn initialize(
141
147
  &mut self,
@@ -144,6 +150,7 @@ impl RHEEDStreamer {
144
150
  chunk_size: usize,
145
151
  stream_name: Option<String>,
146
152
  physical_sample: Option<String>,
153
+ project_id: Option<String>,
147
154
  ) -> PyResult<String> {
148
155
  // Guard: chunk_size must be >= ceil(2 * fps)
149
156
  let min_chunk = (2.0 * fps).ceil() as usize;
@@ -165,6 +172,10 @@ impl RHEEDStreamer {
165
172
  .map(|s| s.trim().to_string())
166
173
  .filter(|s| !s.is_empty());
167
174
 
175
+ let project_id = project_id
176
+ .map(|s| s.trim().to_string())
177
+ .filter(|s| !s.is_empty());
178
+
168
179
  let fpr = (fps * 60.0) / rotations_per_min;
169
180
 
170
181
  #[allow(clippy::redundant_field_names)]
@@ -173,6 +184,7 @@ impl RHEEDStreamer {
173
184
  rotational_period: fpr,
174
185
  rotations_per_min,
175
186
  fps_capture_rate: fps,
187
+ project_id,
176
188
  };
177
189
 
178
190
  let base_endpoint = self.endpoint.clone();
@@ -192,9 +204,24 @@ impl RHEEDStreamer {
192
204
  &data_id,
193
205
  &sample_name,
194
206
  );
195
- self.rt
207
+ let sample_id = self
208
+ .rt
196
209
  .block_on(physical_sample_fut)
197
210
  .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
211
+
212
+ // If project_id was provided, update the project's tracking_physical_sample_id
213
+ if let Some(ref proj_id) = settings.project_id {
214
+ let update_project_fut = update_project_tracking_sample(
215
+ &self.client,
216
+ &base_endpoint,
217
+ &self.api_key,
218
+ proj_id,
219
+ &sample_id,
220
+ );
221
+ self.rt
222
+ .block_on(update_project_fut)
223
+ .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
224
+ }
198
225
  }
199
226
 
200
227
  self.fps = Some(fps);
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomscale
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: Python SDK for Atomscale.
5
5
  Author-email: Atomscale <info@atomscale.ai>
6
6
  License: GPL-3.0-only
@@ -95,6 +95,7 @@ src/atomscale/timeseries/rheed.py
95
95
  src/atomscale/timeseries/sample.py
96
96
  src/atomscale/timeseries/similarity.py
97
97
  tests/__init__.py
98
+ tests/_mock_http_server.py
98
99
  tests/conftest.py
99
100
  tests/test_atomicds_alias.py
100
101
  tests/test_client.py
@@ -105,6 +106,7 @@ tests/test_photoluminescence.py
105
106
  tests/test_polling.py
106
107
  tests/test_raman.py
107
108
  tests/test_rheed_image.py
109
+ tests/test_rheed_stream.py
108
110
  tests/test_rheed_video.py
109
111
  tests/test_similarity_trajectory.py
110
112
  tests/test_xps.py
@@ -0,0 +1,122 @@
1
+ """Subprocess-based mock HTTP server for testing Rust HTTP clients.
2
+
3
+ This module is designed to be run as a subprocess to provide true process
4
+ isolation when testing Rust HTTP clients (like reqwest) from Python tests.
5
+
6
+ Usage:
7
+ python -m tests._mock_http_server <port> <json_response>
8
+ python -m tests._mock_http_server <port> <json_routes_dict>
9
+
10
+ The server:
11
+ - Listens on 127.0.0.1:<port>
12
+ - Prints "READY:<port>" to stdout when ready
13
+ - For simple mode: handles one POST request, prints "BODY:<json>" to stdout
14
+ - For routes mode: handles multiple requests based on path matching
15
+ - Responds with the provided JSON response
16
+ """
17
+ import json
18
+ import sys
19
+ from http.server import BaseHTTPRequestHandler, HTTPServer
20
+
21
+
22
+ class CaptureHandler(BaseHTTPRequestHandler):
23
+ """HTTP handler that captures request body and returns configured response."""
24
+
25
+ def _handle_request(self, method: str):
26
+ """Common handler for all HTTP methods."""
27
+ # Read request body if present
28
+ length = int(self.headers.get("Content-Length", 0))
29
+ body = self.rfile.read(length) if length > 0 else b""
30
+
31
+ path = self.path
32
+ routes = getattr(self.server, "routes", None)
33
+
34
+ if routes:
35
+ # Routes mode: find matching route
36
+ response_data = None
37
+ for route_path, route_response in routes.items():
38
+ if path.startswith(route_path):
39
+ response_data = route_response
40
+ break
41
+
42
+ if response_data is None:
43
+ # Default response for unmatched routes
44
+ response_data = '""'
45
+
46
+ # Print request info for debugging
47
+ print(f"REQUEST:{method}:{path}:{body.decode() if body else ''}", flush=True)
48
+ else:
49
+ # Simple mode: single response for all requests
50
+ response_data = self.server.response_data
51
+ print(f"BODY:{body.decode()}", flush=True)
52
+
53
+ # Send response
54
+ self.send_response(200)
55
+ self.send_header("Content-Type", "application/json")
56
+ self.send_header("Content-Length", len(response_data))
57
+ self.end_headers()
58
+ self.wfile.write(response_data.encode())
59
+
60
+ def do_GET(self):
61
+ self._handle_request("GET")
62
+
63
+ def do_POST(self):
64
+ self._handle_request("POST")
65
+
66
+ def do_PUT(self):
67
+ self._handle_request("PUT")
68
+
69
+ def log_message(self, format, *args):
70
+ """Suppress default logging."""
71
+ pass
72
+
73
+
74
+ class MultiRequestServer(HTTPServer):
75
+ """HTTP server that can handle multiple requests."""
76
+
77
+ def __init__(self, *args, max_requests: int = 1, **kwargs):
78
+ super().__init__(*args, **kwargs)
79
+ self.max_requests = max_requests
80
+ self.request_count = 0
81
+
82
+ def handle_requests(self):
83
+ """Handle up to max_requests requests."""
84
+ while self.request_count < self.max_requests:
85
+ self.handle_request()
86
+ self.request_count += 1
87
+
88
+
89
+ def run_server(port: int, response_data: str) -> None:
90
+ """Run the mock HTTP server."""
91
+ # Try to parse as routes dict
92
+ try:
93
+ routes = json.loads(response_data)
94
+ if isinstance(routes, dict) and routes.get("__routes__"):
95
+ # Routes mode
96
+ del routes["__routes__"]
97
+ max_requests = routes.pop("__max_requests__", 10)
98
+ server = MultiRequestServer(
99
+ ("127.0.0.1", port), CaptureHandler, max_requests=max_requests
100
+ )
101
+ server.routes = routes
102
+ print(f"READY:{port}", flush=True)
103
+ server.handle_requests()
104
+ return
105
+ except (json.JSONDecodeError, TypeError):
106
+ pass
107
+
108
+ # Simple mode: single request with static response
109
+ server = HTTPServer(("127.0.0.1", port), CaptureHandler)
110
+ server.response_data = response_data
111
+ print(f"READY:{port}", flush=True)
112
+ server.handle_request()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ if len(sys.argv) != 3:
117
+ print(f"Usage: {sys.argv[0]} <port> <json_response>", file=sys.stderr)
118
+ sys.exit(1)
119
+
120
+ port = int(sys.argv[1])
121
+ response_data = sys.argv[2]
122
+ run_server(port, response_data)
@@ -0,0 +1,279 @@
1
+ """Integration tests for RHEEDStreamer.
2
+
3
+ These tests use a subprocess-based HTTP server because the RHEEDStreamer uses
4
+ a Rust HTTP client (reqwest) which doesn't interact well with Python's threading
5
+ model used by pytest-httpserver. The subprocess approach provides true process
6
+ isolation and works reliably across platforms.
7
+ """
8
+ import json
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+ import pytest
16
+
17
+ # Path to the mock server module
18
+ _MOCK_SERVER_MODULE = Path(__file__).parent / "_mock_http_server.py"
19
+
20
+
21
+ class MockServer:
22
+ """A mock HTTP server running in a subprocess."""
23
+
24
+ def __init__(self, port: int, response_data: str):
25
+ self.port = port
26
+ self.response_data = response_data
27
+ self._proc: subprocess.Popen | None = None
28
+ self._captured_body: dict | None = None
29
+
30
+ def start(self) -> None:
31
+ """Start the server subprocess."""
32
+ self._proc = subprocess.Popen(
33
+ [sys.executable, str(_MOCK_SERVER_MODULE), str(self.port), self.response_data],
34
+ stdout=subprocess.PIPE,
35
+ stderr=subprocess.PIPE,
36
+ text=True,
37
+ )
38
+ # Wait for server to signal it's ready
39
+ ready_line = self._proc.stdout.readline()
40
+ if not ready_line.startswith("READY:"):
41
+ self.stop()
42
+ raise RuntimeError(f"Server failed to start: {ready_line}")
43
+
44
+ def stop(self) -> None:
45
+ """Stop the server subprocess."""
46
+ if self._proc:
47
+ self._proc.terminate()
48
+ self._proc.wait(timeout=5)
49
+ self._proc = None
50
+
51
+ @property
52
+ def endpoint(self) -> str:
53
+ """Return the server endpoint URL (no trailing slash)."""
54
+ return f"http://127.0.0.1:{self.port}"
55
+
56
+ def get_captured_body(self) -> dict | None:
57
+ """Read and return the captured request body from the server."""
58
+ if self._proc and self._captured_body is None:
59
+ for line in self._proc.stdout:
60
+ if line.startswith("BODY:"):
61
+ self._captured_body = json.loads(line[5:])
62
+ break
63
+ return self._captured_body
64
+
65
+ def __enter__(self) -> "MockServer":
66
+ self.start()
67
+ return self
68
+
69
+ def __exit__(self, *args) -> None:
70
+ self.stop()
71
+
72
+
73
+ def _get_free_port() -> int:
74
+ """Get an available port on localhost."""
75
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
76
+ s.bind(("127.0.0.1", 0))
77
+ return s.getsockname()[1]
78
+
79
+
80
+ @pytest.fixture
81
+ def mock_server_factory() -> Callable[[str], MockServer]:
82
+ """Factory fixture to create mock servers with custom responses."""
83
+ servers: list[MockServer] = []
84
+
85
+ def create(response_data: str) -> MockServer:
86
+ server = MockServer(_get_free_port(), response_data)
87
+ server.start()
88
+ servers.append(server)
89
+ return server
90
+
91
+ yield create
92
+
93
+ for server in servers:
94
+ server.stop()
95
+
96
+
97
+ class TestRHEEDStreamerInitialize:
98
+ """Tests for RHEEDStreamer.initialize() method."""
99
+
100
+ def test_initialize_accepts_project_id_parameter(self):
101
+ """Verify initialize() signature includes project_id parameter."""
102
+ import inspect
103
+
104
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
105
+
106
+ sig = inspect.signature(RHEEDStreamer.initialize)
107
+ params = list(sig.parameters.keys())
108
+
109
+ assert "project_id" in params
110
+ assert sig.parameters["project_id"].default is None
111
+
112
+ def test_initialize_validates_chunk_size(self):
113
+ """Verify chunk_size validation (must be >= 2 * fps)."""
114
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
115
+
116
+ streamer = RHEEDStreamer(
117
+ api_key="test-api-key",
118
+ endpoint="http://localhost:9999",
119
+ )
120
+
121
+ with pytest.raises(ValueError, match="chunk_size must be at least 2×fps"):
122
+ streamer.initialize(
123
+ fps=30.0,
124
+ rotations_per_min=0.0,
125
+ chunk_size=30, # Invalid: less than 2 * 30 = 60
126
+ )
127
+
128
+ def test_initialize_sends_project_id_in_request(self, mock_server_factory):
129
+ """Verify project_id is included in POST body when provided."""
130
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
131
+
132
+ server = mock_server_factory('"test-data-id-123"')
133
+
134
+ streamer = RHEEDStreamer(
135
+ api_key="test-api-key",
136
+ endpoint=server.endpoint,
137
+ )
138
+
139
+ project_uuid = "550e8400-e29b-41d4-a716-446655440000"
140
+ data_id = streamer.initialize(
141
+ fps=30.0,
142
+ rotations_per_min=0.0,
143
+ chunk_size=60,
144
+ project_id=project_uuid,
145
+ )
146
+
147
+ assert data_id == "test-data-id-123"
148
+
149
+ body = server.get_captured_body()
150
+ assert body is not None
151
+ assert body.get("project_id") == project_uuid
152
+ assert "data_item_name" in body
153
+ assert body.get("fps_capture_rate") == 30.0
154
+
155
+ def test_initialize_omits_project_id_when_none(self, mock_server_factory):
156
+ """Verify project_id is omitted from POST body when not provided."""
157
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
158
+
159
+ server = mock_server_factory('"test-data-id-456"')
160
+
161
+ streamer = RHEEDStreamer(
162
+ api_key="test-api-key",
163
+ endpoint=server.endpoint,
164
+ )
165
+
166
+ data_id = streamer.initialize(
167
+ fps=30.0,
168
+ rotations_per_min=0.0,
169
+ chunk_size=60,
170
+ )
171
+
172
+ assert data_id == "test-data-id-456"
173
+
174
+ body = server.get_captured_body()
175
+ assert body is not None
176
+ assert "project_id" not in body
177
+
178
+ def test_initialize_omits_project_id_when_empty_string(self, mock_server_factory):
179
+ """Verify empty string project_id is treated as None (omitted)."""
180
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
181
+
182
+ server = mock_server_factory('"test-data-id-789"')
183
+
184
+ streamer = RHEEDStreamer(
185
+ api_key="test-api-key",
186
+ endpoint=server.endpoint,
187
+ )
188
+
189
+ data_id = streamer.initialize(
190
+ fps=30.0,
191
+ rotations_per_min=0.0,
192
+ chunk_size=60,
193
+ project_id="",
194
+ )
195
+
196
+ assert data_id == "test-data-id-789"
197
+
198
+ body = server.get_captured_body()
199
+ assert body is not None
200
+ assert "project_id" not in body
201
+
202
+ def test_initialize_returns_data_id(self, mock_server_factory):
203
+ """Verify initialize() returns the data_id from the server."""
204
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
205
+
206
+ expected_data_id = "abc-123-xyz"
207
+ server = mock_server_factory(f'"{expected_data_id}"')
208
+
209
+ streamer = RHEEDStreamer(
210
+ api_key="test-api-key",
211
+ endpoint=server.endpoint,
212
+ )
213
+
214
+ data_id = streamer.initialize(
215
+ fps=30.0,
216
+ rotations_per_min=0.0,
217
+ chunk_size=60,
218
+ )
219
+
220
+ assert data_id == expected_data_id
221
+
222
+ def test_initialize_updates_project_config_when_physical_sample_and_project_id(
223
+ self, mock_server_factory
224
+ ):
225
+ """Verify project configuration is updated with tracking_physical_sample_id.
226
+
227
+ When both physical_sample and project_id are provided, the SDK should:
228
+ 1. POST /rheed/stream/ to create the stream
229
+ 2. GET /physical_samples/ to list existing samples
230
+ 3. POST /physical_samples/ to create the sample (if not found)
231
+ 4. POST /data_entries/physical_sample to link sample to data entry
232
+ 5. GET /projects/ to get current project configuration
233
+ 6. POST /projects/{id}/configuration to update tracking_physical_sample_id
234
+ """
235
+ from atomscale.streaming.rheed_stream import RHEEDStreamer
236
+
237
+ project_uuid = "550e8400-e29b-41d4-a716-446655440000"
238
+ sample_uuid = "660e8400-e29b-41d4-a716-446655440001"
239
+
240
+ # Configure routes for the multi-request flow
241
+ # Note: /physical_samples/ is used for both GET (returns list) and POST (returns created sample)
242
+ # The mock returns the same response for both, which works because:
243
+ # - GET expects a list - we return a list with the sample already existing
244
+ # - This skips the POST /physical_samples/ call since sample already exists
245
+ routes = json.dumps({
246
+ "__routes__": True,
247
+ "__max_requests__": 6,
248
+ "/rheed/stream/": '"test-data-id-999"',
249
+ "/physical_samples/": json.dumps([{"id": sample_uuid, "name": "Test Sample"}]),
250
+ "/data_entries/physical_sample": '"OK"',
251
+ "/projects/": json.dumps([{
252
+ "id": project_uuid,
253
+ "name": "Test Project",
254
+ "configuration": {
255
+ "api_configuration": {
256
+ "reference_group_type": "categorical",
257
+ "onboarding_complete": True
258
+ }
259
+ }
260
+ }]),
261
+ f"/projects/{project_uuid}/configuration": '"OK"',
262
+ })
263
+
264
+ server = mock_server_factory(routes)
265
+
266
+ streamer = RHEEDStreamer(
267
+ api_key="test-api-key",
268
+ endpoint=server.endpoint,
269
+ )
270
+
271
+ data_id = streamer.initialize(
272
+ fps=30.0,
273
+ rotations_per_min=0.0,
274
+ chunk_size=60,
275
+ physical_sample="Test Sample",
276
+ project_id=project_uuid,
277
+ )
278
+
279
+ assert data_id == "test-data-id-999"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes