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.
- hse_sampletracker_client-0.1.1.dist-info/METADATA +238 -0
- hse_sampletracker_client-0.1.1.dist-info/RECORD +12 -0
- hse_sampletracker_client-0.1.1.dist-info/WHEEL +4 -0
- hse_sampletracker_client-0.1.1.dist-info/entry_points.txt +2 -0
- hse_sampletracker_client-0.1.1.dist-info/licenses/LICENSE.txt +18 -0
- sampletracker/__about__.py +4 -0
- sampletracker/__init__.py +28 -0
- sampletracker/__main__.py +9 -0
- sampletracker/client.py +164 -0
- sampletracker/exceptions.py +42 -0
- sampletracker/models.py +248 -0
- sampletracker/run.py +247 -0
|
@@ -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
|
+
[](https://pypi.org/project/hse-sampletracker-client)
|
|
34
|
+
[](https://pypi.org/project/hse-sampletracker-client)
|
|
35
|
+
[](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,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,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
|
+
]
|
sampletracker/client.py
ADDED
|
@@ -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
|
sampletracker/models.py
ADDED
|
@@ -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())
|