scictl 0.0.1__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.
scictl-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sciforium
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
scictl-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: scictl
3
+ Version: 0.0.1
4
+ Summary: Sciforium command-line interface tool
5
+ Author: Purna Doddapaneni
6
+ Author-email: Purna Doddapaneni <purna@sciforium.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: click>=8.5.0
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: tqdm>=4.66
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+
15
+ # scictl
16
+
17
+ The command-line interface to Sciforium's ecosystem. Today that means registering
18
+ datasets and uploading files straight to Sciforium storage for SpecFoundry, with more
19
+ features coming soon.
20
+
21
+ ## Installation
22
+
23
+ ```sh
24
+ # with uv (recommended)
25
+ uv tool install scictl
26
+
27
+ # with pip
28
+ pip install scictl
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Visit [sciforium.com](https://sciforium.com) to provision an API key.
34
+
35
+ ```sh
36
+ scictl dataset upload training-data data.jsonl --api-key "$SCIFORIUM_API_KEY"
37
+ ```
38
+
39
+ - **`dataset upload`** — registers the dataset to your account, and uploads to storage.
40
+ - **`version`** — `scictl version`; pass `--json` for scripts and bug reports.
41
+
42
+ Requires Python 3.10+.
scictl-0.0.1/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # scictl
2
+
3
+ The command-line interface to Sciforium's ecosystem. Today that means registering
4
+ datasets and uploading files straight to Sciforium storage for SpecFoundry, with more
5
+ features coming soon.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ # with uv (recommended)
11
+ uv tool install scictl
12
+
13
+ # with pip
14
+ pip install scictl
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Visit [sciforium.com](https://sciforium.com) to provision an API key.
20
+
21
+ ```sh
22
+ scictl dataset upload training-data data.jsonl --api-key "$SCIFORIUM_API_KEY"
23
+ ```
24
+
25
+ - **`dataset upload`** — registers the dataset to your account, and uploads to storage.
26
+ - **`version`** — `scictl version`; pass `--json` for scripts and bug reports.
27
+
28
+ Requires Python 3.10+.
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "scictl"
3
+ version = "0.0.1"
4
+ description = "Sciforium command-line interface tool"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "click>=8.5.0",
11
+ "httpx>=0.27",
12
+ "tqdm>=4.66",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Purna Doddapaneni"
17
+ email = "purna@sciforium.com"
18
+
19
+ [project.scripts]
20
+ scictl = "scictl.cli:cli"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "packaging>=25",
25
+ "pytest>=8.0.0",
26
+ "ruff>=0.14.0",
27
+ ]
28
+
29
+ [tool.ruff]
30
+ target-version = "py310"
31
+
32
+ [tool.ruff.lint]
33
+ select = [
34
+ "E4",
35
+ "E7",
36
+ "E9",
37
+ "F",
38
+ "I",
39
+ "UP",
40
+ "B",
41
+ "S",
42
+ ]
43
+
44
+ [tool.ruff.lint.per-file-ignores]
45
+ "tests/**" = ["S101"]
46
+
47
+ [tool.ruff.format]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+
52
+ [build-system]
53
+ requires = ["uv_build>=0.12.10,<0.13.0"]
54
+ build-backend = "uv_build"
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "scictl"
3
+ version = "0.0.1"
4
+ description = "Sciforium command-line interface tool"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Purna Doddapaneni", email = "purna@sciforium.com" }
10
+ ]
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "click>=8.5.0",
14
+ "httpx>=0.27",
15
+ "tqdm>=4.66",
16
+ ]
17
+
18
+ [project.scripts]
19
+ scictl = "scictl.cli:cli"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "packaging>=25",
24
+ "pytest>=8.0.0",
25
+ "ruff>=0.14.0",
26
+ ]
27
+
28
+ [tool.ruff]
29
+ target-version = "py310"
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E4", "E7", "E9", "F", "I", "UP", "B", "S"]
33
+
34
+ [tool.ruff.lint.per-file-ignores]
35
+ "tests/**" = ["S101"]
36
+
37
+ [tool.ruff.format]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.12.10,<0.13.0"]
44
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ """scictl — upload JSONL files to Sciforium storage."""
@@ -0,0 +1,171 @@
1
+ """Sciforium API and storage client.
2
+
3
+ The upload flow has two steps:
4
+
5
+ 1. ``create_dataset`` registers the dataset with the API and receives a
6
+ short-lived presigned S3 PUT URL.
7
+ 2. ``upload_file`` PUTs the file bytes directly to that URL.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import sys
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import httpx
20
+ from tqdm import tqdm
21
+
22
+ from scictl.version import get_version
23
+
24
+ BASE_URL = "https://api.sciforium.com"
25
+ DATASETS_PATH = "/v1/datasets"
26
+
27
+ #: Chunk size used when streaming the file to storage. Heuristically chosen to balance speed and memory usage.
28
+ CHUNK_SIZE_BYTES = 8 * 1024 * 1024
29
+
30
+ #: Storage uploads may take a long time, so the write timeout is disabled.
31
+ UPLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=None, pool=10.0)
32
+
33
+
34
+ class ApiError(Exception):
35
+ def __init__(
36
+ self,
37
+ status: int,
38
+ message: str,
39
+ *,
40
+ cause: str | None = None,
41
+ ) -> None:
42
+ super().__init__(message)
43
+ self.status = status
44
+ self.message = message
45
+ self.cause = cause
46
+
47
+
48
+ class ApiUnreachable(ApiError):
49
+ def __init__(self, cause: str) -> None:
50
+ super().__init__(0, "request failed", cause=cause)
51
+
52
+
53
+ def create_client() -> httpx.Client:
54
+ version = get_version()
55
+ return httpx.Client(
56
+ headers={
57
+ "User-Agent": f"scictl/{version}",
58
+ "X-Scictl-Version": version,
59
+ }
60
+ )
61
+
62
+
63
+ def create_storage_client() -> httpx.Client:
64
+ return httpx.Client()
65
+
66
+
67
+ def create_dataset(
68
+ api_key: str,
69
+ name: str,
70
+ file_size_bytes: int,
71
+ *,
72
+ client: httpx.Client,
73
+ ) -> dict[str, Any]:
74
+ """Register a dataset and return the parsed ``201`` response body."""
75
+ try:
76
+ response = client.post(
77
+ f"{BASE_URL}{DATASETS_PATH}",
78
+ headers={"x-api-key": api_key, "accept": "application/json"},
79
+ json={"name": name, "file_size_bytes": file_size_bytes},
80
+ )
81
+ except httpx.HTTPError as exc:
82
+ raise ApiUnreachable(str(exc)) from exc
83
+
84
+ if response.status_code != 201:
85
+ raise _api_error(response)
86
+ body = _parse_json_object(response)
87
+ if body is None:
88
+ raise ApiError(response.status_code, "unexpected response body")
89
+ return body
90
+
91
+
92
+ def upload_file(
93
+ upload: dict[str, Any],
94
+ file_path: Path,
95
+ file_size_bytes: int,
96
+ *,
97
+ client: httpx.Client,
98
+ ) -> None:
99
+ headers = {
100
+ str(key): str(value) for key, value in (upload.get("headers") or {}).items()
101
+ }
102
+ headers["Content-Length"] = str(file_size_bytes)
103
+
104
+ with file_path.open("rb") as handle:
105
+ chunks = _read_chunks(handle, file_size_bytes, file_path.name)
106
+ try:
107
+ response = client.put(
108
+ upload["url"],
109
+ headers=headers,
110
+ content=chunks,
111
+ timeout=UPLOAD_TIMEOUT,
112
+ )
113
+ except httpx.HTTPError as exc:
114
+ raise ApiUnreachable(str(exc)) from exc
115
+
116
+ if not response.is_success:
117
+ raise _storage_error(response)
118
+
119
+
120
+ def _read_chunks(handle: Any, total: int, description: str) -> Iterator[bytes]:
121
+ with tqdm(
122
+ total=total,
123
+ desc=description,
124
+ unit="B",
125
+ unit_scale=True,
126
+ unit_divisor=1000,
127
+ file=sys.stderr,
128
+ ) as bar:
129
+ while chunk := handle.read(CHUNK_SIZE_BYTES):
130
+ bar.update(len(chunk))
131
+ yield chunk
132
+
133
+
134
+ def _api_error(response: httpx.Response) -> ApiError:
135
+ message = response.reason_phrase or f"HTTP {response.status_code}"
136
+ cause: str | None = None
137
+
138
+ body = _parse_json_object(response)
139
+ if body is not None:
140
+ if isinstance(body.get("message"), str) and body["message"]:
141
+ message = body["message"]
142
+ value = body.get("cause")
143
+ if isinstance(value, str) and value:
144
+ cause = value
145
+
146
+ return ApiError(response.status_code, message, cause=cause)
147
+
148
+
149
+ def _storage_error(response: httpx.Response) -> ApiError:
150
+ """Parse an S3 XML error body into an :class:`ApiError`."""
151
+ message = response.reason_phrase or f"HTTP {response.status_code}"
152
+
153
+ text = response.text or ""
154
+ detail = _xml_element(text, "Message")
155
+ if detail:
156
+ message = detail
157
+
158
+ return ApiError(response.status_code, message)
159
+
160
+
161
+ def _xml_element(body: str, tag: str) -> str | None:
162
+ match = re.search(rf"<{tag}>(.*?)</{tag}>", body, re.DOTALL)
163
+ return match.group(1).strip() or None if match else None
164
+
165
+
166
+ def _parse_json_object(response: httpx.Response) -> dict[str, Any] | None:
167
+ try:
168
+ body = response.json()
169
+ except (json.JSONDecodeError, ValueError):
170
+ return None
171
+ return body if isinstance(body, dict) else None
@@ -0,0 +1,22 @@
1
+ """Command-line interface for scictl."""
2
+
3
+ import click
4
+
5
+ from scictl.commands.dataset import dataset
6
+ from scictl.commands.version import version
7
+ from scictl.metavars import OPTIONS_METAVAR, SUBCOMMAND_METAVAR
8
+
9
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
10
+
11
+
12
+ @click.group(
13
+ context_settings=CONTEXT_SETTINGS,
14
+ options_metavar=OPTIONS_METAVAR,
15
+ subcommand_metavar=SUBCOMMAND_METAVAR,
16
+ )
17
+ def cli() -> None:
18
+ """scictl — upload JSONL files to Sciforium storage."""
19
+
20
+
21
+ cli.add_command(dataset)
22
+ cli.add_command(version)
@@ -0,0 +1 @@
1
+ """Command implementations for the scictl CLI."""
@@ -0,0 +1,14 @@
1
+ """The ``dataset`` command group."""
2
+
3
+ import click
4
+
5
+ from scictl.commands.dataset.upload import upload
6
+ from scictl.metavars import OPTIONS_METAVAR, SUBCOMMAND_METAVAR
7
+
8
+
9
+ @click.group(options_metavar=OPTIONS_METAVAR, subcommand_metavar=SUBCOMMAND_METAVAR)
10
+ def dataset() -> None:
11
+ """Manage customer datasets."""
12
+
13
+
14
+ dataset.add_command(upload)
@@ -0,0 +1,105 @@
1
+ """The ``dataset upload`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from scictl import api
10
+ from scictl.metavars import OPTIONS_METAVAR
11
+
12
+ CONNECT_MESSAGE = "Could not reach api.sciforium.com. Please retry your request."
13
+ UNEXPECTED_RESPONSE_MESSAGE = (
14
+ "The API returned an unexpected response. Please retry your request."
15
+ )
16
+
17
+
18
+ @click.command(options_metavar=OPTIONS_METAVAR)
19
+ @click.argument("dataset_name", metavar="<dataset_name>")
20
+ @click.argument(
21
+ "file_path",
22
+ metavar="<file_path>",
23
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
24
+ )
25
+ @click.option(
26
+ "--api-key",
27
+ default=None,
28
+ metavar="<key>",
29
+ help="Sciforium API key. Prompted for (hidden) if omitted.",
30
+ )
31
+ def upload(dataset_name: str, file_path: Path, api_key: str | None) -> None:
32
+ """Register <dataset_name> and upload a local JSONL file to Sciforium storage."""
33
+ if api_key is None:
34
+ api_key = click.prompt("Sciforium API key", hide_input=True)
35
+ key = (api_key or "").strip()
36
+ if not key:
37
+ raise click.UsageError(
38
+ "An API key is required. Provision one at https://console.sciforium.com/keys"
39
+ )
40
+
41
+ file_size_bytes = file_path.stat().st_size
42
+ if file_path.suffix.lower() != ".jsonl":
43
+ click.secho(
44
+ f"warning: {file_path.name} does not end in .jsonl", fg="yellow", err=True
45
+ )
46
+
47
+ with api.create_client() as client:
48
+ try:
49
+ created = api.create_dataset(
50
+ key, dataset_name, file_size_bytes, client=client
51
+ )
52
+ except api.ApiUnreachable as exc:
53
+ raise click.ClickException(CONNECT_MESSAGE) from exc
54
+ except api.ApiError as exc:
55
+ raise click.ClickException(_display_message(exc)) from exc
56
+
57
+ presigned = _presigned_upload(created)
58
+ if presigned is None:
59
+ raise click.ClickException(UNEXPECTED_RESPONSE_MESSAGE)
60
+
61
+ with api.create_storage_client() as storage_client:
62
+ try:
63
+ api.upload_file(
64
+ presigned, file_path, file_size_bytes, client=storage_client
65
+ )
66
+ except api.ApiUnreachable as exc:
67
+ raise click.ClickException(
68
+ "Lost the connection while uploading to storage. Please try again."
69
+ ) from exc
70
+ except api.ApiError as exc:
71
+ raise click.ClickException(_upload_message(exc)) from exc
72
+
73
+ click.echo(f"Uploaded '{dataset_name}' successfully!")
74
+
75
+
76
+ def _presigned_upload(created: dict) -> dict | None:
77
+ """Return the validated ``upload`` object from a create response, or ``None``."""
78
+ presigned = created.get("upload")
79
+ if not isinstance(presigned, dict):
80
+ return None
81
+ url = presigned.get("url")
82
+ if not isinstance(url, str) or not url:
83
+ return None
84
+ headers = presigned.get("headers")
85
+ if headers is not None and not isinstance(headers, dict):
86
+ return None
87
+ return presigned
88
+
89
+
90
+ def _display_message(error: api.ApiError) -> str:
91
+ """Show the API-owned copy, preferring ``cause`` over ``message`` (web parity)."""
92
+ return error.cause or error.message
93
+
94
+
95
+ def _upload_message(error: api.ApiError) -> str:
96
+ if error.status == 412:
97
+ reason = (
98
+ "A file was already uploaded for this dataset, so the upload was rejected. "
99
+ "Use a different name to upload a new file."
100
+ )
101
+ elif error.status == 403 and "expired" in error.message.lower():
102
+ reason = "The upload URL expired before the file finished (presigned URLs last 15 minutes)."
103
+ else:
104
+ reason = f"Upload to storage failed (HTTP {error.status}): {error.message}"
105
+ return reason
@@ -0,0 +1,26 @@
1
+ """The ``version`` command."""
2
+
3
+ import json
4
+ import platform
5
+
6
+ import click
7
+
8
+ from scictl.metavars import OPTIONS_METAVAR
9
+ from scictl.version import PACKAGE_NAME, get_version
10
+
11
+
12
+ @click.command(options_metavar=OPTIONS_METAVAR)
13
+ @click.option("--json", "as_json", is_flag=True, help="Print the version as JSON.")
14
+ def version(as_json: bool) -> None:
15
+ """Show the scictl version and the runtime it is running on."""
16
+ if as_json:
17
+ payload = {
18
+ "name": PACKAGE_NAME,
19
+ "version": get_version(),
20
+ "python": platform.python_version(),
21
+ "platform": f"{platform.system().lower()}-{platform.machine()}",
22
+ }
23
+ click.echo(json.dumps(payload))
24
+ return
25
+
26
+ click.echo(f"{PACKAGE_NAME} version {get_version()}")
@@ -0,0 +1,8 @@
1
+ """Usage-line placeholders shared by every command.
2
+
3
+ Click renders positional arguments and its ``[OPTIONS]`` / ``COMMAND [ARGS]...`` slots
4
+ in ALL CAPS by default; scictl shows them as ``[options]`` and ``<name>`` instead.
5
+ """
6
+
7
+ OPTIONS_METAVAR = "[options]"
8
+ SUBCOMMAND_METAVAR = "<command> [args]..."
@@ -0,0 +1,23 @@
1
+ """The scictl version, read from installed distribution metadata."""
2
+
3
+ import importlib.metadata
4
+
5
+ PACKAGE_NAME = "scictl"
6
+
7
+ #: Returned when distribution metadata is unavailable, e.g. a source checkout that was
8
+ #: never installed. This is a valid PEP 440 local version that sorts below every real
9
+ #: release, so a version-aware server reads an unidentifiable client as older than any
10
+ #: minimum supported version rather than newer.
11
+ UNKNOWN_VERSION = "0.0.0+unknown"
12
+
13
+
14
+ def get_version() -> str:
15
+ """Return the scictl version as a PEP 440 string.
16
+
17
+ Reads the version recorded when the distribution was installed, so this is the same
18
+ value the package was built with rather than whatever is on disk now.
19
+ """
20
+ try:
21
+ return importlib.metadata.version(PACKAGE_NAME)
22
+ except importlib.metadata.PackageNotFoundError:
23
+ return UNKNOWN_VERSION