hse-sampletracker-client 0.1.1__py3-none-any.whl

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,238 @@
1
+ Metadata-Version: 2.4
2
+ Name: hse-sampletracker-client
3
+ Version: 0.1.1
4
+ Summary: A Pythonic client library for the SampleTracker SaaS API
5
+ Project-URL: Documentation, https://github.com/Alexander Schramm/sampletracker#readme
6
+ Project-URL: Issues, https://github.com/Alexander Schramm/sampletracker/issues
7
+ Project-URL: Source, https://github.com/Alexander Schramm/sampletracker
8
+ Author-email: Alexander Schramm <info@expectiq.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE.txt
11
+ Keywords: api,client,hse,laboratory,samples,sampletracker
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: Implementation :: CPython
23
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: httpx>=0.24.0
29
+ Description-Content-Type: text/markdown
30
+
31
+ # HSE SampleTracker Client
32
+
33
+ [![PyPI - Version](https://img.shields.io/pypi/v/hse-sampletracker-client.svg)](https://pypi.org/project/hse-sampletracker-client)
34
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hse-sampletracker-client.svg)](https://pypi.org/project/hse-sampletracker-client)
35
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
36
+
37
+ A Pythonic client library for the HSE SampleTracker API - manage laboratory samples, processing steps, and observations with ease.
38
+
39
+ ## Features
40
+
41
+ - **Synchronous API Client** - Simple, blocking HTTP client using `httpx`
42
+ - **Type Hints Throughout** - Fully typed for better IDE support and code safety
43
+ - **Command-Line Interface** - Interactive CLI for quick sample management
44
+ - **Pydantic-like Models** - Dataclass-based models with `from_dict` constructors
45
+ - **Comprehensive Exceptions** - Clear error hierarchy for different failure modes
46
+ - **Environment Configuration** - Support for `.env` files and environment variables
47
+ - **Pagination Support** - Built-in cursor-based pagination for listing samples
48
+
49
+ ## Installation
50
+
51
+ ```console
52
+ pip install hse-sampletracker-client
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ### Using the Python API
58
+
59
+ ```python
60
+ from sampletracker import SampleTrackerClient
61
+
62
+ # Create a client
63
+ client = SampleTrackerClient(
64
+ api_key="your-api-key",
65
+ base_url="https://your-instance.com/api/v1"
66
+ )
67
+
68
+ # List samples
69
+ samples = client.list_samples(limit=10)
70
+ for sample in samples:
71
+ print(f"{sample.sample_id}: {sample.name}")
72
+
73
+ # Get detailed sample information
74
+ sample = client.load_sample("SAMPLE-123")
75
+ print(f"Description: {sample.description}")
76
+ print(f"Steps: {sample.stats.step_count}")
77
+ print(f"Observations: {sample.stats.observation_count}")
78
+
79
+ # View history
80
+ for event in sample.history:
81
+ if hasattr(event, 'name'): # StepEvent
82
+ print(f"Step: {event.name}")
83
+ else: # ObservationEvent
84
+ print(f"Observation: {event.content}")
85
+ ```
86
+
87
+ ### Using the CLI
88
+
89
+ Set up your environment:
90
+
91
+ ```bash
92
+ # Option 1: Environment variables
93
+ export SAMPLETRACKER_API_KEY="your-api-key"
94
+ export SAMPLETRACKER_BASE_URL="https://your-instance.com/api/v1"
95
+
96
+ # Option 2: Create a .env file
97
+ echo "SAMPLETRACKER_API_KEY=your-api-key" > .env
98
+ echo "SAMPLETRACKER_BASE_URL=https://your-instance.com/api/v1" >> .env
99
+ ```
100
+
101
+ List samples:
102
+
103
+ ```console
104
+ $ sampletracker list --limit 5
105
+ ID | Sample ID | Name | Steps | Observations
106
+ --------------------------------------------------------------------------------
107
+ 1 | sample-001 | test batch | 3 | 2
108
+ 2 | M315SO23 | test batch (Copy) | 3 | 2
109
+ ```
110
+
111
+ View sample details:
112
+
113
+ ```console
114
+ $ sampletracker view sample-001
115
+ Sample Details:
116
+ ID: 1
117
+ Sample ID: sample-001
118
+ Name: test batch
119
+ Description: mein erster test
120
+ Owner ID: 2
121
+ Owner Email: admin@example.com
122
+ Created: 2026-04-25 13:14:13.126000+00:00
123
+ Updated: 2026-04-25 13:14:13.126000+00:00
124
+ Steps: 2
125
+ Observations: 2
126
+
127
+ History:
128
+ [Step] Backen
129
+ Performed: 2026-04-25 13:20:00+00:00
130
+ Parameters:
131
+ - Temperatur: 210 °C
132
+ [Observation] Das Ding ist heiß
133
+ Created: 2026-04-25 13:21:06.172000+00:00
134
+ ```
135
+
136
+ ## Configuration
137
+
138
+ ### Environment Variables
139
+
140
+ | Variable | Description | Required |
141
+ |----------|-------------|----------|
142
+ | `SAMPLETRACKER_API_KEY` | Your API key | Yes |
143
+ | `SAMPLETRACKER_BASE_URL` | API base URL | No (defaults to official SaaS) |
144
+
145
+ ### .env File
146
+
147
+ Create a `.env` file in your project root:
148
+
149
+ ```
150
+ SAMPLETRACKER_API_KEY=your-api-key
151
+ SAMPLETRACKER_BASE_URL=https://your-instance.com/api/v1
152
+ ```
153
+
154
+ The CLI automatically loads variables from `.env` files in the current or parent directories.
155
+
156
+ ## API Reference
157
+
158
+ ### SampleTrackerClient
159
+
160
+ The main client class for interacting with the API.
161
+
162
+ ```python
163
+ client = SampleTrackerClient(
164
+ api_key: str, # Your API key
165
+ base_url: str | None, # Optional base URL
166
+ timeout: float | None # Optional timeout (default: 30s)
167
+ )
168
+ ```
169
+
170
+ ### Methods
171
+
172
+ #### `list_samples(limit: int = 10, after_id: int | None = None) -> list[SampleListItem]`
173
+
174
+ List samples with pagination support.
175
+
176
+ - `limit`: Number of samples to return (max: 100)
177
+ - `after_id`: Cursor for pagination (returns samples with ID > after_id)
178
+
179
+ #### `load_sample(sample_id: str) -> Sample | None`
180
+
181
+ Get detailed information about a specific sample, including history.
182
+
183
+ Returns `None` if the sample doesn't exist.
184
+
185
+ ### Models
186
+
187
+ #### SampleListItem
188
+
189
+ Lightweight model for list responses:
190
+
191
+ - `id`: Internal database ID
192
+ - `sample_id`: User-facing sample identifier
193
+ - `name`: Sample name
194
+ - `description`: Optional description
195
+ - `owner_id`: Owner's user ID
196
+ - `created_at`: Creation timestamp
197
+ - `updated_at`: Last update timestamp
198
+ - `step_count`: Number of processing steps
199
+ - `observation_count`: Number of observations
200
+
201
+ #### Sample
202
+
203
+ Detailed model with full history:
204
+
205
+ - All fields from `SampleListItem`, plus:
206
+ - `owner_email`: Email of the owner
207
+ - `history`: List of `StepEvent` and `ObservationEvent`
208
+ - `stats`: `SampleStats` with aggregated counts
209
+
210
+ ### Exceptions
211
+
212
+ All exceptions inherit from `SampleTrackerError`:
213
+
214
+ - `SampleTrackerAuthError` - Authentication failed (401)
215
+ - `SampleTrackerNotFoundError` - Resource not found (404)
216
+ - `SampleTrackerValidationError` - Request validation failed (422)
217
+ - `SampleTrackerRateLimitError` - Rate limit exceeded (429)
218
+ - `SampleTrackerAPIError` - Other API errors
219
+
220
+ ## Supported Python Versions
221
+
222
+ - Python 3.10+
223
+ - Python 3.11+
224
+ - Python 3.12+
225
+ - Python 3.13+
226
+ - Python 3.14+
227
+ - PyPy 3.10+
228
+
229
+ ## License
230
+
231
+ `hse-sampletracker-client` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
232
+
233
+ ## Links
234
+
235
+ - **Documentation**: https://github.com/Alexander Schramm/sampletracker#readme
236
+ - **Issues**: https://github.com/Alexander Schramm/sampletracker/issues
237
+ - **Source**: https://github.com/Alexander Schramm/sampletracker
238
+ - **PyPI**: https://pypi.org/project/hse-sampletracker-client
@@ -0,0 +1,12 @@
1
+ sampletracker/__about__.py,sha256=K6bS7LuwO0rIivSI3klu2Pwkt5GN_rpKMF_KetE0bqY,112
2
+ sampletracker/__init__.py,sha256=Tv4QpZAggX0L3sx4zR4t2v4QPhAEh-1gTAtkLuGcgB8,728
3
+ sampletracker/__main__.py,sha256=alTBn5AbxyvHtmiyjWmVWcDA69gmJXVuVph5BfOzWvU,186
4
+ sampletracker/client.py,sha256=kQ0BiL_Tx-NGuZ3vTmq0wxLiIjhknbWHt0MgPOvZ52Q,5433
5
+ sampletracker/exceptions.py,sha256=YFkDqBWV7G_wqYYDU4SqvhCNUsregnHtk7YT2DBcIYU,1211
6
+ sampletracker/models.py,sha256=EjVg3oSasdLlaPT30KDjN-yecTPEUqLG_skWaE9BSD8,7810
7
+ sampletracker/run.py,sha256=D3ksHYJIUEmNeMZjVhbmmJmsFlQe-_onjQEuJF9TlYA,7956
8
+ hse_sampletracker_client-0.1.1.dist-info/METADATA,sha256=4inQ_jPuhuZkSaBIzviI-RZQchXxnKPm-FzQ7h66dB0,7420
9
+ hse_sampletracker_client-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ hse_sampletracker_client-0.1.1.dist-info/entry_points.txt,sha256=BjKNRQ8TBIBmPLcxy5taBB8pb1p2gZa4C1OH-Qep8lw,57
11
+ hse_sampletracker_client-0.1.1.dist-info/licenses/LICENSE.txt,sha256=S9jT5nNiRR4e9s4E7hgy2CTR46T7lRpz6y1RpFZ5V0s,1114
12
+ hse_sampletracker_client-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sampletracker = sampletracker.run:main
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Alexander Schramm <alexander.schramm96@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Alexander Schramm
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.1.1"
@@ -0,0 +1,28 @@
1
+ # SPDX-FileCopyrightText: 2026-present Alexander Schramm
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """SampleTracker API client library."""
6
+
7
+ from sampletracker.client import SampleTrackerClient
8
+ from sampletracker.exceptions import (
9
+ SampleTrackerAPIError,
10
+ SampleTrackerAuthError,
11
+ SampleTrackerError,
12
+ SampleTrackerNotFoundError,
13
+ SampleTrackerRateLimitError,
14
+ SampleTrackerValidationError,
15
+ )
16
+ from sampletracker.models import Sample, SampleListItem
17
+
18
+ __all__ = [
19
+ "SampleTrackerClient",
20
+ "Sample",
21
+ "SampleListItem",
22
+ "SampleTrackerError",
23
+ "SampleTrackerAPIError",
24
+ "SampleTrackerAuthError",
25
+ "SampleTrackerNotFoundError",
26
+ "SampleTrackerValidationError",
27
+ "SampleTrackerRateLimitError",
28
+ ]
@@ -0,0 +1,9 @@
1
+ """Entry point for running sampletracker as a module.
2
+
3
+ Allows running the CLI via: python -m sampletracker
4
+ """
5
+
6
+ from sampletracker.run import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,164 @@
1
+ """Main client for interacting with the SampleTracker API."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from sampletracker.exceptions import (
8
+ SampleTrackerAPIError,
9
+ SampleTrackerAuthError,
10
+ SampleTrackerNotFoundError,
11
+ SampleTrackerRateLimitError,
12
+ SampleTrackerValidationError,
13
+ )
14
+ from sampletracker.models import Sample, SampleListItem
15
+
16
+
17
+ class SampleTrackerClient:
18
+ """A synchronous client for the SampleTracker API.
19
+
20
+ This client provides methods to interact with the SampleTracker SaaS API
21
+ in a Pythonic way.
22
+
23
+ Args:
24
+ api_key: Your SampleTracker API key.
25
+ base_url: The base URL of the SampleTracker API.
26
+ Defaults to https://sampletracker.com/api/v1/.
27
+ timeout: Request timeout in seconds. Defaults to 30.
28
+
29
+ Example:
30
+ >>> from sampletracker import SampleTrackerClient
31
+ >>> client = SampleTrackerClient(api_key="your-api-key")
32
+ >>> samples = client.list_samples()
33
+ """
34
+
35
+ DEFAULT_BASE_URL = "https://sampletracker.com/api/v1/"
36
+ DEFAULT_TIMEOUT = 30.0
37
+
38
+ def __init__(
39
+ self,
40
+ api_key: str,
41
+ base_url: str | None = None,
42
+ timeout: float | None = None,
43
+ ) -> None:
44
+ self.api_key = api_key
45
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
46
+ self.timeout = timeout or self.DEFAULT_TIMEOUT
47
+ self._client: httpx.Client | None = None
48
+
49
+ def _get_client(self) -> httpx.Client:
50
+ """Get or create the HTTP client."""
51
+ if self._client is None:
52
+ self._client = httpx.Client(
53
+ base_url=self.base_url,
54
+ headers={
55
+ "Authorization": f"Bearer {self.api_key}",
56
+ "Content-Type": "application/json",
57
+ "Accept": "application/json",
58
+ },
59
+ timeout=self.timeout,
60
+ )
61
+ return self._client
62
+
63
+ def _handle_error(self, response: httpx.Response) -> None:
64
+ """Handle API error responses."""
65
+ try:
66
+ response.raise_for_status()
67
+ except httpx.HTTPStatusError as e:
68
+ status_code = e.response.status_code
69
+ try:
70
+ body = e.response.json()
71
+ except Exception:
72
+ body = {"message": e.response.text}
73
+
74
+ message = body.get("message", str(e))
75
+
76
+ if status_code == 401:
77
+ raise SampleTrackerAuthError(message, status_code, body) from e
78
+ elif status_code == 404:
79
+ raise SampleTrackerNotFoundError(message, status_code, body) from e
80
+ elif status_code == 422:
81
+ raise SampleTrackerValidationError(message, status_code, body) from e
82
+ elif status_code == 429:
83
+ retry_after = e.response.headers.get("Retry-After")
84
+ raise SampleTrackerRateLimitError(
85
+ message, retry_after=int(retry_after) if retry_after else None
86
+ ) from e
87
+ else:
88
+ raise SampleTrackerAPIError(message, status_code, body) from e
89
+
90
+ def _request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ **kwargs: Any,
95
+ ) -> dict[str, Any]:
96
+ """Make an HTTP request to the API."""
97
+ client = self._get_client()
98
+ response = client.request(method, path, **kwargs)
99
+ self._handle_error(response)
100
+ return response.json()
101
+
102
+ def _get(self, path: str, **kwargs: Any) -> dict[str, Any]:
103
+ """Make a GET request."""
104
+ return self._request("GET", path, **kwargs)
105
+
106
+ def _post(self, path: str, **kwargs: Any) -> dict[str, Any]:
107
+ """Make a POST request."""
108
+ return self._request("POST", path, **kwargs)
109
+
110
+ def _put(self, path: str, **kwargs: Any) -> dict[str, Any]:
111
+ """Make a PUT request."""
112
+ return self._request("PUT", path, **kwargs)
113
+
114
+ def _patch(self, path: str, **kwargs: Any) -> dict[str, Any]:
115
+ """Make a PATCH request."""
116
+ return self._request("PATCH", path, **kwargs)
117
+
118
+ def _delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
119
+ """Make a DELETE request."""
120
+ return self._request("DELETE", path, **kwargs)
121
+
122
+
123
+
124
+
125
+ def load_sample(self, sample_id: str) -> Sample | None:
126
+ """Loads a sample by its ID.
127
+
128
+ Returns:
129
+ The loaded sample
130
+ """
131
+ if sample_id is None or sample_id.strip() == "":
132
+ return None
133
+ response = self._get(f"/samples/{sample_id}")
134
+ response_data = response.get("data")
135
+
136
+ if response_data is None:
137
+ return None
138
+
139
+ sample = Sample.from_dict(response_data)
140
+ return sample
141
+
142
+
143
+ def list_samples(self, limit: int = 10, after_id: int | None = None) -> list[SampleListItem]:
144
+ """List all samples
145
+
146
+ Args:
147
+ limit: Number of results to return (max: 100). Defaults to 10.
148
+ after_id: Pagination cursor - return samples with ID > after_id (maps to minId).
149
+
150
+ Returns:
151
+ List of sample list items
152
+ """
153
+ params: dict[str, Any] = {"l": limit}
154
+ if after_id is not None:
155
+ params["minId"] = after_id
156
+
157
+ response = self._get("/samples", params=params)
158
+ response_data = response.get("data", [])
159
+
160
+ if not response_data:
161
+ return []
162
+
163
+ samples = [SampleListItem.from_dict(item) for item in response_data]
164
+ return samples
@@ -0,0 +1,42 @@
1
+ """Custom exceptions for the SampleTracker API client."""
2
+
3
+
4
+ class SampleTrackerError(Exception):
5
+ """Base exception for all SampleTracker errors."""
6
+
7
+ pass
8
+
9
+
10
+ class SampleTrackerAPIError(SampleTrackerError):
11
+ """Raised when the API returns an error response."""
12
+
13
+ def __init__(self, message: str, status_code: int | None = None, response_body: dict | None = None) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+ self.response_body = response_body or {}
17
+
18
+
19
+ class SampleTrackerAuthError(SampleTrackerAPIError):
20
+ """Raised when authentication fails (401 Unauthorized)."""
21
+
22
+ pass
23
+
24
+
25
+ class SampleTrackerNotFoundError(SampleTrackerAPIError):
26
+ """Raised when a resource is not found (404 Not Found)."""
27
+
28
+ pass
29
+
30
+
31
+ class SampleTrackerValidationError(SampleTrackerAPIError):
32
+ """Raised when request validation fails (422 Unprocessable Entity)."""
33
+
34
+ pass
35
+
36
+
37
+ class SampleTrackerRateLimitError(SampleTrackerAPIError):
38
+ """Raised when rate limit is exceeded (429 Too Many Requests)."""
39
+
40
+ def __init__(self, message: str, retry_after: int | None = None) -> None:
41
+ super().__init__(message, status_code=429)
42
+ self.retry_after = retry_after
@@ -0,0 +1,248 @@
1
+ """Data models for the SampleTracker API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class SampleListItem:
12
+ """Represents a sample item in the list response.
13
+
14
+ This is the lightweight model used in the list samples endpoint.
15
+
16
+ Attributes:
17
+ id: The internal database ID of the sample.
18
+ sample_id: The user-facing sample identifier (e.g., "ABC123").
19
+ name: The name of the sample.
20
+ description: Optional description of the sample.
21
+ owner_id: The ID of the sample owner.
22
+ created_at: When the sample was created.
23
+ updated_at: When the sample was last updated.
24
+ step_count: Number of processing steps.
25
+ observation_count: Number of observations.
26
+ """
27
+
28
+ id: int
29
+ sample_id: str
30
+ name: str | None
31
+ description: str | None
32
+ owner_id: int
33
+ created_at: datetime
34
+ updated_at: datetime
35
+ step_count: int = 0
36
+ observation_count: int = 0
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict[str, Any]) -> SampleListItem:
40
+ """Create a SampleListItem instance from a dictionary.
41
+
42
+ Args:
43
+ data: Dictionary containing sample data from the list API.
44
+
45
+ Returns:
46
+ A new SampleListItem instance.
47
+ """
48
+ return cls(
49
+ id=data["id"],
50
+ sample_id=data["sampleId"],
51
+ name=data.get("name"),
52
+ description=data.get("description"),
53
+ owner_id=data["ownerId"],
54
+ created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
55
+ updated_at=datetime.fromisoformat(data["updatedAt"].replace("Z", "+00:00")),
56
+ step_count=data.get("stepCount", 0),
57
+ observation_count=data.get("observationCount", 0),
58
+ )
59
+
60
+ def __repr__(self) -> str:
61
+ """Return a string representation of the sample."""
62
+ return f"SampleListItem(id={self.id}, sample_id='{self.sample_id}', name='{self.name}')"
63
+
64
+
65
+ @dataclass
66
+ class Parameter:
67
+ """Represents a parameter in a step.
68
+
69
+ Attributes:
70
+ id: The parameter ID.
71
+ key: The parameter name/key.
72
+ value: The parameter value.
73
+ unit: The unit of measurement (optional).
74
+ """
75
+
76
+ id: int
77
+ key: str
78
+ value: Any
79
+ unit: str | None
80
+
81
+ @classmethod
82
+ def from_dict(cls, data: dict[str, Any]) -> Parameter:
83
+ """Create a Parameter instance from a dictionary."""
84
+ return cls(
85
+ id=data["id"],
86
+ key=data["key"],
87
+ value=data["value"],
88
+ unit=data.get("unit"),
89
+ )
90
+
91
+
92
+ @dataclass
93
+ class StepEvent:
94
+ """Represents a processing step in the sample history.
95
+
96
+ Attributes:
97
+ id: The step ID.
98
+ name: The step name.
99
+ description: Optional description.
100
+ performed_by: User ID who performed the step.
101
+ recorded_by: User ID who recorded the step.
102
+ performed_at: When the step was performed.
103
+ created_at: When the step was recorded.
104
+ parameters: List of parameters for this step.
105
+ """
106
+
107
+ id: int
108
+ name: str
109
+ description: str | None
110
+ performed_by: int
111
+ recorded_by: int
112
+ performed_at: datetime
113
+ created_at: datetime
114
+ parameters: list[Parameter]
115
+
116
+ @classmethod
117
+ def from_dict(cls, data: dict[str, Any]) -> StepEvent:
118
+ """Create a StepEvent instance from a dictionary."""
119
+ return cls(
120
+ id=data["id"],
121
+ name=data["name"],
122
+ description=data.get("description"),
123
+ performed_by=data["performedBy"],
124
+ recorded_by=data["recordedBy"],
125
+ performed_at=datetime.fromisoformat(data["performedAt"].replace("Z", "+00:00")),
126
+ created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
127
+ parameters=[Parameter.from_dict(p) for p in data.get("parameters", [])],
128
+ )
129
+
130
+
131
+ @dataclass
132
+ class ObservationEvent:
133
+ """Represents an observation in the sample history.
134
+
135
+ Attributes:
136
+ id: The observation ID.
137
+ content: The observation text.
138
+ user_id: User ID who made the observation.
139
+ created_at: When the observation was created.
140
+ updated_at: When the observation was last updated.
141
+ """
142
+
143
+ id: int
144
+ content: str
145
+ user_id: int
146
+ created_at: datetime
147
+ updated_at: datetime
148
+
149
+ @classmethod
150
+ def from_dict(cls, data: dict[str, Any]) -> ObservationEvent:
151
+ """Create an ObservationEvent instance from a dictionary."""
152
+ return cls(
153
+ id=data["id"],
154
+ content=data["content"],
155
+ user_id=data["userId"],
156
+ created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
157
+ updated_at=datetime.fromisoformat(data["updatedAt"].replace("Z", "+00:00")),
158
+ )
159
+
160
+
161
+ @dataclass
162
+ class SampleStats:
163
+ """Statistics about a sample.
164
+
165
+ Attributes:
166
+ step_count: Number of processing steps.
167
+ observation_count: Number of observations.
168
+ """
169
+
170
+ step_count: int
171
+ observation_count: int
172
+
173
+ @classmethod
174
+ def from_dict(cls, data: dict[str, Any]) -> SampleStats:
175
+ """Create a SampleStats instance from a dictionary."""
176
+ return cls(
177
+ step_count=data.get("stepCount", 0),
178
+ observation_count=data.get("observationCount", 0),
179
+ )
180
+
181
+
182
+ HistoryEvent = StepEvent | ObservationEvent
183
+
184
+
185
+ @dataclass
186
+ class Sample:
187
+ """Represents a detailed sample with full history.
188
+
189
+ This is the full model used in the get sample details endpoint.
190
+
191
+ Attributes:
192
+ id: The internal database ID of the sample.
193
+ sample_id: The user-facing sample identifier (e.g., "ABC123").
194
+ name: The name of the sample.
195
+ description: Optional description of the sample.
196
+ owner_id: The ID of the sample owner.
197
+ owner_email: Email of the sample owner.
198
+ created_at: When the sample was created.
199
+ updated_at: When the sample was last updated.
200
+ history: List of history events (steps and observations).
201
+ stats: Statistics about the sample.
202
+ """
203
+
204
+ id: int
205
+ sample_id: str
206
+ name: str | None
207
+ description: str | None
208
+ owner_id: int
209
+ owner_email: str
210
+ created_at: datetime
211
+ updated_at: datetime
212
+ history: list[HistoryEvent]
213
+ stats: SampleStats
214
+
215
+ @classmethod
216
+ def from_dict(cls, data: dict[str, Any]) -> Sample:
217
+ """Create a Sample instance from a dictionary.
218
+
219
+ Args:
220
+ data: Dictionary containing sample data from the detail API.
221
+
222
+ Returns:
223
+ A new Sample instance.
224
+ """
225
+ history: list[HistoryEvent] = []
226
+ for event_data in data.get("history", []):
227
+ event_type = event_data.get("type")
228
+ if event_type == "step":
229
+ history.append(StepEvent.from_dict(event_data))
230
+ elif event_type == "observation":
231
+ history.append(ObservationEvent.from_dict(event_data))
232
+
233
+ return cls(
234
+ id=data["id"],
235
+ sample_id=data["sampleId"],
236
+ name=data.get("name"),
237
+ description=data.get("description"),
238
+ owner_id=data["ownerId"],
239
+ owner_email=data["ownerEmail"],
240
+ created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
241
+ updated_at=datetime.fromisoformat(data["updatedAt"].replace("Z", "+00:00")),
242
+ history=history,
243
+ stats=SampleStats.from_dict(data.get("stats", {})),
244
+ )
245
+
246
+ def __repr__(self) -> str:
247
+ """Return a string representation of the sample."""
248
+ return f"Sample(id={self.id}, sample_id='{self.sample_id}', name='{self.name}')"
sampletracker/run.py ADDED
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """CLI for interacting with the SampleTracker API.
3
+
4
+ This module provides a command-line interface for listing samples
5
+ and viewing sample details.
6
+
7
+ Examples:
8
+ # List all samples (default limit: 10)
9
+ python -m sampletracker.run list
10
+
11
+ # List samples with a specific limit
12
+ python -m sampletracker.run list --limit 20
13
+
14
+ # View a specific sample's details
15
+ python -m sampletracker.run view SAMPLE-123
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Sequence
25
+
26
+ from sampletracker.client import SampleTrackerClient
27
+ from sampletracker.exceptions import SampleTrackerError
28
+ from sampletracker.models import ObservationEvent, SampleListItem, StepEvent
29
+
30
+
31
+ def _load_env_file() -> None:
32
+ """Load environment variables from .env file if it exists."""
33
+ # Look for .env file in the current directory and parent directories
34
+ current_dir = Path.cwd()
35
+ for parent in [current_dir] + list(current_dir.parents):
36
+ env_file = parent / ".env"
37
+ if env_file.exists():
38
+ with open(env_file) as f:
39
+ for line in f:
40
+ line = line.strip()
41
+ if line and not line.startswith("#") and "=" in line:
42
+ key, value = line.split("=", 1)
43
+ # Only set if not already set in environment
44
+ if key not in os.environ:
45
+ os.environ[key] = value
46
+ break
47
+
48
+
49
+ # Load .env file on module import
50
+ _load_env_file()
51
+
52
+
53
+ def create_parser() -> argparse.ArgumentParser:
54
+ """Create and configure the argument parser."""
55
+ parser = argparse.ArgumentParser(
56
+ prog="sampletracker",
57
+ description="CLI for interacting with the SampleTracker API",
58
+ formatter_class=argparse.RawDescriptionHelpFormatter,
59
+ epilog="""
60
+ Configuration (in order of precedence):
61
+ 1. Command-line options (--api-key, --base-url)
62
+ 2. Environment variables (SAMPLETRACKER_API_KEY, SAMPLETRACKER_BASE_URL)
63
+ 3. .env file in current or parent directory (gitignored)
64
+
65
+ Examples:
66
+ %(prog)s list # List first 10 samples
67
+ %(prog)s list --limit 20 # List first 20 samples
68
+ %(prog)s view SAMPLE-123 # View sample details
69
+ """,
70
+ )
71
+
72
+ parser.add_argument(
73
+ "--api-key",
74
+ type=str,
75
+ default=os.environ.get("SAMPLETRACKER_API_KEY"),
76
+ help="Your SampleTracker API key (can also be set via SAMPLETRACKER_API_KEY env var)",
77
+ )
78
+
79
+ parser.add_argument(
80
+ "--base-url",
81
+ type=str,
82
+ default=os.environ.get("SAMPLETRACKER_BASE_URL"),
83
+ help="The base URL of the SampleTracker API",
84
+ )
85
+
86
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
87
+
88
+ # List command
89
+ list_parser = subparsers.add_parser(
90
+ "list",
91
+ help="List all samples",
92
+ description="List samples from the SampleTracker API",
93
+ )
94
+ list_parser.add_argument(
95
+ "--limit",
96
+ "-l",
97
+ type=int,
98
+ default=10,
99
+ help="Number of samples to return (max: 100, default: 10)",
100
+ )
101
+ list_parser.add_argument(
102
+ "--after-id",
103
+ "-a",
104
+ type=int,
105
+ default=None,
106
+ help="Pagination cursor - return samples with ID > after_id",
107
+ )
108
+
109
+ # View command
110
+ view_parser = subparsers.add_parser(
111
+ "view",
112
+ help="View a specific sample's details",
113
+ description="View detailed information about a sample",
114
+ )
115
+ view_parser.add_argument(
116
+ "sample_id",
117
+ type=str,
118
+ help="The sample ID to look up (e.g., 'ABC123')",
119
+ )
120
+
121
+ return parser
122
+
123
+
124
+ def get_client(args: argparse.Namespace) -> SampleTrackerClient:
125
+ """Create a client instance from parsed arguments."""
126
+ if not args.api_key:
127
+ print(
128
+ "Error: API key is required. Set it via --api-key option or SAMPLETRACKER_API_KEY environment variable.",
129
+ file=sys.stderr,
130
+ )
131
+ sys.exit(1)
132
+
133
+ return SampleTrackerClient(
134
+ api_key=args.api_key,
135
+ base_url=args.base_url if args.base_url else None,
136
+ )
137
+
138
+
139
+ def cmd_list(client: SampleTrackerClient, args: argparse.Namespace) -> int:
140
+ """Execute the list command.
141
+
142
+ Returns:
143
+ Exit code (0 for success, 1 for failure)
144
+ """
145
+ try:
146
+ samples = client.list_samples(limit=args.limit, after_id=args.after_id)
147
+
148
+ if not samples:
149
+ print("No samples found.")
150
+ return 0
151
+
152
+ print(f"{'ID':>6} | {'Sample ID':>15} | {'Name':>30} | {'Steps':>5} | {'Observations':>12}")
153
+ print("-" * 80)
154
+
155
+ for sample in samples:
156
+ name = sample.name or "(unnamed)"
157
+ name = name[:28] + ".." if len(name) > 30 else name
158
+ print(
159
+ f"{sample.id:>6} | {sample.sample_id:>15} | {name:>30} | "
160
+ f"{sample.step_count:>5} | {sample.observation_count:>12}"
161
+ )
162
+
163
+ return 0
164
+
165
+ except SampleTrackerError as e:
166
+ print(f"Error: {e}", file=sys.stderr)
167
+ return 1
168
+
169
+
170
+ def cmd_view(client: SampleTrackerClient, args: argparse.Namespace) -> int:
171
+ """Execute the view command.
172
+
173
+ Returns:
174
+ Exit code (0 for success, 1 for failure)
175
+ """
176
+ try:
177
+ sample = client.load_sample(args.sample_id)
178
+
179
+ if sample is None:
180
+ print(f"Sample '{args.sample_id}' not found.", file=sys.stderr)
181
+ return 1
182
+
183
+ print(f"Sample Details:")
184
+ print(f" ID: {sample.id}")
185
+ print(f" Sample ID: {sample.sample_id}")
186
+ print(f" Name: {sample.name or '(unnamed)'}")
187
+ print(f" Description: {sample.description or '(none)'}")
188
+ print(f" Owner ID: {sample.owner_id}")
189
+ print(f" Owner Email: {sample.owner_email}")
190
+ print(f" Created: {sample.created_at}")
191
+ print(f" Updated: {sample.updated_at}")
192
+ print(f" Steps: {sample.stats.step_count}")
193
+ print(f" Observations: {sample.stats.observation_count}")
194
+
195
+ if sample.history:
196
+ print(f"\n History:")
197
+ for event in sample.history:
198
+ if isinstance(event, StepEvent):
199
+ print(f" [Step] {event.name}")
200
+ if event.description:
201
+ print(f" Description: {event.description}")
202
+ print(f" Performed: {event.performed_at}")
203
+ if event.parameters:
204
+ print(f" Parameters:")
205
+ for param in event.parameters:
206
+ unit = f" {param.unit}" if param.unit else ""
207
+ print(f" - {param.key}: {param.value}{unit}")
208
+ elif isinstance(event, ObservationEvent):
209
+ print(f" [Observation] {event.content[:50]}{'...' if len(event.content) > 50 else ''}")
210
+ print(f" Created: {event.created_at}")
211
+
212
+ return 0
213
+
214
+ except SampleTrackerError as e:
215
+ print(f"Error: {e}", file=sys.stderr)
216
+ return 1
217
+
218
+
219
+ def main(args: Sequence[str] | None = None) -> int:
220
+ """Main entry point for the CLI.
221
+
222
+ Args:
223
+ args: Command-line arguments (defaults to sys.argv[1:])
224
+
225
+ Returns:
226
+ Exit code (0 for success, non-zero for failure)
227
+ """
228
+ parser = create_parser()
229
+ parsed_args = parser.parse_args(args)
230
+
231
+ if not parsed_args.command:
232
+ parser.print_help()
233
+ return 1
234
+
235
+ client = get_client(parsed_args)
236
+
237
+ if parsed_args.command == "list":
238
+ return cmd_list(client, parsed_args)
239
+ elif parsed_args.command == "view":
240
+ return cmd_view(client, parsed_args)
241
+ else:
242
+ parser.print_help()
243
+ return 1
244
+
245
+
246
+ if __name__ == "__main__":
247
+ sys.exit(main())