scictl 0.0.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.
- scictl/__init__.py +1 -0
- scictl/api.py +171 -0
- scictl/cli.py +22 -0
- scictl/commands/__init__.py +1 -0
- scictl/commands/dataset/__init__.py +14 -0
- scictl/commands/dataset/upload.py +105 -0
- scictl/commands/version.py +26 -0
- scictl/metavars.py +8 -0
- scictl/version.py +23 -0
- scictl-0.0.1.dist-info/METADATA +42 -0
- scictl-0.0.1.dist-info/RECORD +14 -0
- scictl-0.0.1.dist-info/WHEEL +4 -0
- scictl-0.0.1.dist-info/entry_points.txt +3 -0
- scictl-0.0.1.dist-info/licenses/LICENSE +21 -0
scictl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""scictl — upload JSONL files to Sciforium storage."""
|
scictl/api.py
ADDED
|
@@ -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
|
scictl/cli.py
ADDED
|
@@ -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()}")
|
scictl/metavars.py
ADDED
|
@@ -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]..."
|
scictl/version.py
ADDED
|
@@ -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
|
|
@@ -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+.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
scictl/__init__.py,sha256=N1A1O7VwExNCyQx2ca6e4xzpmwj582jQ9AOKgz6JBac,58
|
|
2
|
+
scictl/api.py,sha256=Q4ClHn1mwWNszNgcH3Er_-fGmtL5uZSgzqIAQ-8zFXk,4723
|
|
3
|
+
scictl/cli.py,sha256=rUMWHqwuWopKsEH3o_MmslBEyp2ps3_T2CM7w6CJOSQ,538
|
|
4
|
+
scictl/commands/__init__.py,sha256=_SzUfwOQ3gnyQMzeOoL3Jf5mvBlRuTOpR4gheVllK1g,50
|
|
5
|
+
scictl/commands/dataset/__init__.py,sha256=mID9jXy8q2ZIdEN9N_MVIwuPSz89pU38e45Gp8PyGsM,342
|
|
6
|
+
scictl/commands/dataset/upload.py,sha256=p-fiiXt1ipU1v_D1J6puqiVC35eUvURJ6X7MDuky0h0,3713
|
|
7
|
+
scictl/commands/version.py,sha256=lidrfuPoBfOhcg73sHet9rYM0OB7-wTarpmgtLGhBaA,768
|
|
8
|
+
scictl/metavars.py,sha256=LFFkqGJW4M_CGSje9EdNB84KuCKAHoJtXKsrVuD8Go0,301
|
|
9
|
+
scictl/version.py,sha256=zlkVaWZ4wNKRpvAwck6fkVEcS1fU_4fPYEqzyeMzos0,865
|
|
10
|
+
scictl-0.0.1.dist-info/licenses/LICENSE,sha256=t-FNn93kbcEudzyCmUBbQsHV3aFFUiQgYmzrOhD1CrM,1066
|
|
11
|
+
scictl-0.0.1.dist-info/WHEEL,sha256=Kot-FOXwz2no6rGIg4et79Z7m6vcKPAXr2Y6fTFpZuI,81
|
|
12
|
+
scictl-0.0.1.dist-info/entry_points.txt,sha256=2uZQfCMcEvR2-RCn1WJuOahe8cqQh2nVsgoH-AYizy4,43
|
|
13
|
+
scictl-0.0.1.dist-info/METADATA,sha256=1vZhBy0lEJwC3ZOhUyFM5CAsAUBRooa6NqDdnPgD2oQ,1041
|
|
14
|
+
scictl-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|