scripticus-server 0.1.1__tar.gz → 0.2.0__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.
- {scripticus_server-0.1.1 → scripticus_server-0.2.0}/PKG-INFO +25 -1
- {scripticus_server-0.1.1 → scripticus_server-0.2.0}/README.md +22 -0
- {scripticus_server-0.1.1 → scripticus_server-0.2.0}/pyproject.toml +6 -1
- scripticus_server-0.2.0/src/scripticus_server/app.py +134 -0
- scripticus_server-0.2.0/src/scripticus_server/db.py +151 -0
- scripticus_server-0.1.1/src/scripticus_server/app.py +0 -29
- {scripticus_server-0.1.1 → scripticus_server-0.2.0}/src/scripticus_server/__init__.py +0 -0
- {scripticus_server-0.1.1 → scripticus_server-0.2.0}/src/scripticus_server/main.py +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scripticus-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A package manager and registry for scripts — index service
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Dist: fastapi>=0.115
|
|
7
7
|
Requires-Dist: uvicorn>=0.30
|
|
8
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
9
|
+
Requires-Dist: scripticus-schema>=0.1.1,<0.2.0
|
|
8
10
|
Requires-Python: >=3.11
|
|
9
11
|
Description-Content-Type: text/markdown
|
|
10
12
|
|
|
@@ -36,6 +38,28 @@ spec at `/openapi.json`.
|
|
|
36
38
|
up. It is deliberately unauthenticated — it's a liveness probe for load
|
|
37
39
|
balancers and container orchestrators.
|
|
38
40
|
|
|
41
|
+
### Version
|
|
42
|
+
|
|
43
|
+
`GET /version` returns the running server's version, e.g.
|
|
44
|
+
`{"version": "0.1.1"}`.
|
|
45
|
+
|
|
46
|
+
### Package index (read API)
|
|
47
|
+
|
|
48
|
+
- `GET /packages/{namespace}/{name}` — a package's version listing, newest
|
|
49
|
+
first by semver precedence. Yanked versions are included and marked
|
|
50
|
+
(`"yanked": true`) so pinned lookups can still see them; unknown packages
|
|
51
|
+
return `404`.
|
|
52
|
+
- `GET /search?q=<substring>&platform=<os>&language=<lang>` — packages whose
|
|
53
|
+
name contains `q` (all parameters optional), with each result's latest
|
|
54
|
+
non-yanked version. Yanked versions are invisible to search; `platform`
|
|
55
|
+
and `language` filter on the artifacts a version actually provides.
|
|
56
|
+
|
|
57
|
+
The index database defaults to a local SQLite file
|
|
58
|
+
(`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
|
|
59
|
+
to point elsewhere. Tables are created automatically on first use. Note
|
|
60
|
+
that publishing doesn't exist yet, so a fresh index is empty until it
|
|
61
|
+
lands.
|
|
62
|
+
|
|
39
63
|
### Docker
|
|
40
64
|
|
|
41
65
|
Server releases publish a Docker image to
|
|
@@ -26,6 +26,28 @@ spec at `/openapi.json`.
|
|
|
26
26
|
up. It is deliberately unauthenticated — it's a liveness probe for load
|
|
27
27
|
balancers and container orchestrators.
|
|
28
28
|
|
|
29
|
+
### Version
|
|
30
|
+
|
|
31
|
+
`GET /version` returns the running server's version, e.g.
|
|
32
|
+
`{"version": "0.1.1"}`.
|
|
33
|
+
|
|
34
|
+
### Package index (read API)
|
|
35
|
+
|
|
36
|
+
- `GET /packages/{namespace}/{name}` — a package's version listing, newest
|
|
37
|
+
first by semver precedence. Yanked versions are included and marked
|
|
38
|
+
(`"yanked": true`) so pinned lookups can still see them; unknown packages
|
|
39
|
+
return `404`.
|
|
40
|
+
- `GET /search?q=<substring>&platform=<os>&language=<lang>` — packages whose
|
|
41
|
+
name contains `q` (all parameters optional), with each result's latest
|
|
42
|
+
non-yanked version. Yanked versions are invisible to search; `platform`
|
|
43
|
+
and `language` filter on the artifacts a version actually provides.
|
|
44
|
+
|
|
45
|
+
The index database defaults to a local SQLite file
|
|
46
|
+
(`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
|
|
47
|
+
to point elsewhere. Tables are created automatically on first use. Note
|
|
48
|
+
that publishing doesn't exist yet, so a fresh index is empty until it
|
|
49
|
+
lands.
|
|
50
|
+
|
|
29
51
|
### Docker
|
|
30
52
|
|
|
31
53
|
Server releases publish a Docker image to
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "scripticus-server"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A package manager and registry for scripts — index service"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -8,8 +8,13 @@ license = "MIT"
|
|
|
8
8
|
dependencies = [
|
|
9
9
|
"fastapi>=0.115",
|
|
10
10
|
"uvicorn>=0.30",
|
|
11
|
+
"sqlalchemy>=2.0",
|
|
12
|
+
"scripticus-schema>=0.1.1,<0.2.0",
|
|
11
13
|
]
|
|
12
14
|
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
scripticus-schema = { workspace = true }
|
|
17
|
+
|
|
13
18
|
[project.scripts]
|
|
14
19
|
scripticus-svr = "scripticus_server.main:main"
|
|
15
20
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends, FastAPI, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
|
|
9
|
+
from scripticus_schema.index_api import (
|
|
10
|
+
PackageSummary,
|
|
11
|
+
PackageVersions,
|
|
12
|
+
SearchResults,
|
|
13
|
+
VersionSummary,
|
|
14
|
+
)
|
|
15
|
+
from scripticus_schema.semver import semver_key
|
|
16
|
+
from scripticus_server import __version__, db
|
|
17
|
+
|
|
18
|
+
app = FastAPI(
|
|
19
|
+
title="Scripticus index service",
|
|
20
|
+
description=(
|
|
21
|
+
"Manifest-aware search, version and dependency resolution, and the "
|
|
22
|
+
"publish path for a Scripticus registry."
|
|
23
|
+
),
|
|
24
|
+
version=__version__,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_session_factory: sessionmaker | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_session() -> Iterator[Session]:
|
|
31
|
+
# Lazy so that importing the app never touches the database; tables are
|
|
32
|
+
# created on first use (D31: create_all until the schema stabilises).
|
|
33
|
+
global _session_factory
|
|
34
|
+
if _session_factory is None:
|
|
35
|
+
engine = db.make_engine()
|
|
36
|
+
db.init_db(engine)
|
|
37
|
+
_session_factory = sessionmaker(bind=engine)
|
|
38
|
+
with _session_factory() as session:
|
|
39
|
+
yield session
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Local to the server on purpose: a liveness shape is not part of the
|
|
43
|
+
# package contract, so it doesn't meet the schema/ admission rule (D29).
|
|
44
|
+
class HealthStatus(BaseModel):
|
|
45
|
+
status: Literal["ok"] = "ok"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Unauthenticated by design: a liveness probe carries nothing worth gating,
|
|
49
|
+
# and the index service stays out of the ACL business anyway (D24). Leave it
|
|
50
|
+
# open even once other endpoints grow auth.
|
|
51
|
+
@app.get("/health")
|
|
52
|
+
def health() -> HealthStatus:
|
|
53
|
+
return HealthStatus()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class VersionInfo(BaseModel):
|
|
57
|
+
version: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.get("/version")
|
|
61
|
+
def version() -> VersionInfo:
|
|
62
|
+
return VersionInfo(version=__version__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.get("/packages/{namespace}/{name}")
|
|
66
|
+
def package_versions(
|
|
67
|
+
namespace: str, name: str, session: Session = Depends(get_session)
|
|
68
|
+
) -> PackageVersions:
|
|
69
|
+
package = session.scalar(
|
|
70
|
+
select(db.Package)
|
|
71
|
+
.join(db.Namespace)
|
|
72
|
+
.where(db.Namespace.name == namespace, db.Package.name == name)
|
|
73
|
+
)
|
|
74
|
+
if package is None:
|
|
75
|
+
raise HTTPException(404, f"no package '{namespace}/{name}' in the index")
|
|
76
|
+
ordered = sorted(
|
|
77
|
+
package.versions, key=lambda pv: semver_key(pv.version), reverse=True
|
|
78
|
+
)
|
|
79
|
+
return PackageVersions(
|
|
80
|
+
namespace=namespace,
|
|
81
|
+
name=name,
|
|
82
|
+
description=next((pv.description for pv in ordered if not pv.yanked), ""),
|
|
83
|
+
versions=[
|
|
84
|
+
VersionSummary(version=pv.version, yanked=pv.yanked) for pv in ordered
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_matching_artifact(
|
|
90
|
+
package_version: db.PackageVersion, platform: str | None, language: str | None
|
|
91
|
+
) -> bool:
|
|
92
|
+
if platform is None and language is None:
|
|
93
|
+
return True
|
|
94
|
+
for artifact in package_version.artifacts:
|
|
95
|
+
if platform is not None and platform not in artifact.platform_list():
|
|
96
|
+
continue
|
|
97
|
+
if language is not None and artifact.language != language:
|
|
98
|
+
continue
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/search")
|
|
104
|
+
def search(
|
|
105
|
+
q: str = "",
|
|
106
|
+
platform: str | None = None,
|
|
107
|
+
language: str | None = None,
|
|
108
|
+
session: Session = Depends(get_session),
|
|
109
|
+
) -> SearchResults:
|
|
110
|
+
packages = session.scalars(
|
|
111
|
+
select(db.Package)
|
|
112
|
+
.join(db.Namespace)
|
|
113
|
+
.where(db.Package.name.contains(q))
|
|
114
|
+
.order_by(db.Namespace.name, db.Package.name)
|
|
115
|
+
).all()
|
|
116
|
+
results = []
|
|
117
|
+
for package in packages:
|
|
118
|
+
candidates = [
|
|
119
|
+
pv
|
|
120
|
+
for pv in package.versions
|
|
121
|
+
if not pv.yanked and _has_matching_artifact(pv, platform, language)
|
|
122
|
+
]
|
|
123
|
+
if not candidates:
|
|
124
|
+
continue
|
|
125
|
+
latest = max(candidates, key=lambda pv: semver_key(pv.version))
|
|
126
|
+
results.append(
|
|
127
|
+
PackageSummary(
|
|
128
|
+
namespace=package.namespace.name,
|
|
129
|
+
name=package.name,
|
|
130
|
+
description=latest.description,
|
|
131
|
+
latest_version=latest.version,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return SearchResults(results=results)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The index data model (ARCHITECTURE.md), SQLite via SQLAlchemy (D23).
|
|
2
|
+
|
|
3
|
+
No SQLite-isms: everything here must work unchanged on Postgres, so the
|
|
4
|
+
database stays a configuration change (``SCRIPTICUS_INDEX_DB``). Tables are
|
|
5
|
+
created with ``create_all`` at startup — no migration tool until the schema
|
|
6
|
+
has released consumers (D31).
|
|
7
|
+
|
|
8
|
+
The extracted columns are a publish-time projection of the verbatim
|
|
9
|
+
manifest blob, never independently editable (D21).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import ForeignKey, Text, UniqueConstraint, create_engine
|
|
15
|
+
from sqlalchemy.engine import Engine
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
17
|
+
|
|
18
|
+
DEFAULT_DB_URL = "sqlite:///scripticus-index.db"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def database_url() -> str:
|
|
22
|
+
return os.environ.get("SCRIPTICUS_INDEX_DB", DEFAULT_DB_URL)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_engine(url: str | None = None) -> Engine:
|
|
26
|
+
return create_engine(url or database_url())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init_db(engine: Engine) -> None:
|
|
30
|
+
Base.metadata.create_all(engine)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Base(DeclarativeBase):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Namespace(Base):
|
|
38
|
+
"""A cached reference to a Gitea user/org — an FK anchor only. Gitea
|
|
39
|
+
remains authoritative for ownership and ACLs; nothing permission-shaped
|
|
40
|
+
is ever stored here (D24).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
__tablename__ = "namespace"
|
|
44
|
+
|
|
45
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
46
|
+
name: Mapped[str] = mapped_column(unique=True)
|
|
47
|
+
|
|
48
|
+
packages: Mapped[list["Package"]] = relationship(back_populates="namespace")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Package(Base):
|
|
52
|
+
__tablename__ = "package"
|
|
53
|
+
__table_args__ = (UniqueConstraint("namespace_id", "name"),)
|
|
54
|
+
|
|
55
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
56
|
+
namespace_id: Mapped[int] = mapped_column(ForeignKey("namespace.id"))
|
|
57
|
+
name: Mapped[str]
|
|
58
|
+
|
|
59
|
+
namespace: Mapped[Namespace] = relationship(back_populates="packages")
|
|
60
|
+
versions: Mapped[list["PackageVersion"]] = relationship(back_populates="package")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PackageVersion(Base):
|
|
64
|
+
"""Immutable once written; yank is a whole-version flag (npm model)."""
|
|
65
|
+
|
|
66
|
+
__tablename__ = "package_version"
|
|
67
|
+
__table_args__ = (UniqueConstraint("package_id", "version"),)
|
|
68
|
+
|
|
69
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
70
|
+
package_id: Mapped[int] = mapped_column(ForeignKey("package.id"))
|
|
71
|
+
version: Mapped[str]
|
|
72
|
+
description: Mapped[str] = mapped_column(default="")
|
|
73
|
+
yanked: Mapped[bool] = mapped_column(default=False)
|
|
74
|
+
published_at: Mapped[str] = mapped_column(default="") # ISO 8601 UTC
|
|
75
|
+
publisher: Mapped[str] = mapped_column(default="")
|
|
76
|
+
|
|
77
|
+
package: Mapped[Package] = relationship(back_populates="versions")
|
|
78
|
+
artifacts: Mapped[list["Artifact"]] = relationship(back_populates="package_version")
|
|
79
|
+
dependencies: Mapped[list["Dependency"]] = relationship()
|
|
80
|
+
tool_deps: Mapped[list["ToolDep"]] = relationship()
|
|
81
|
+
commands: Mapped[list["Command"]] = relationship()
|
|
82
|
+
manifest_blob: Mapped["ManifestBlob | None"] = relationship(
|
|
83
|
+
back_populates="package_version"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Artifact(Base):
|
|
88
|
+
"""One per platform/language variant of a version."""
|
|
89
|
+
|
|
90
|
+
__tablename__ = "artifact"
|
|
91
|
+
|
|
92
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
93
|
+
package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
|
|
94
|
+
# Comma-joined OS names from the manifest's platforms.os — a queryable
|
|
95
|
+
# projection; the manifest blob remains the authoritative list.
|
|
96
|
+
platforms: Mapped[str]
|
|
97
|
+
language: Mapped[str]
|
|
98
|
+
archive_format: Mapped[str] = mapped_column(default="")
|
|
99
|
+
content_hash: Mapped[str] = mapped_column(default="")
|
|
100
|
+
size: Mapped[int] = mapped_column(default=0)
|
|
101
|
+
gitea_pointer: Mapped[str] = mapped_column(default="")
|
|
102
|
+
|
|
103
|
+
package_version: Mapped[PackageVersion] = relationship(back_populates="artifacts")
|
|
104
|
+
|
|
105
|
+
def platform_list(self) -> list[str]:
|
|
106
|
+
return self.platforms.split(",") if self.platforms else []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Dependency(Base):
|
|
110
|
+
__tablename__ = "dependency"
|
|
111
|
+
|
|
112
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
113
|
+
package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
|
|
114
|
+
target: Mapped[str] # fully namespaced: "owner/name"
|
|
115
|
+
spec: Mapped[str] # semver range constraint
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ToolDep(Base):
|
|
119
|
+
__tablename__ = "tool_dep"
|
|
120
|
+
|
|
121
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
122
|
+
package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
|
|
123
|
+
name: Mapped[str]
|
|
124
|
+
required: Mapped[bool] = mapped_column(default=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Command(Base):
|
|
128
|
+
__tablename__ = "command"
|
|
129
|
+
|
|
130
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
131
|
+
package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
|
|
132
|
+
name: Mapped[str]
|
|
133
|
+
script_path: Mapped[str]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ManifestBlob(Base):
|
|
137
|
+
"""The verbatim manifest as published — the authoritative record every
|
|
138
|
+
extracted column above is re-derivable from (D21).
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
__tablename__ = "manifest_blob"
|
|
142
|
+
|
|
143
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
144
|
+
package_version_id: Mapped[int] = mapped_column(
|
|
145
|
+
ForeignKey("package_version.id"), unique=True
|
|
146
|
+
)
|
|
147
|
+
toml: Mapped[str] = mapped_column(Text)
|
|
148
|
+
|
|
149
|
+
package_version: Mapped[PackageVersion] = relationship(
|
|
150
|
+
back_populates="manifest_blob"
|
|
151
|
+
)
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
from typing import Literal
|
|
2
|
-
|
|
3
|
-
from fastapi import FastAPI
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
|
|
6
|
-
from scripticus_server import __version__
|
|
7
|
-
|
|
8
|
-
app = FastAPI(
|
|
9
|
-
title="Scripticus index service",
|
|
10
|
-
description=(
|
|
11
|
-
"Manifest-aware search, version and dependency resolution, and the "
|
|
12
|
-
"publish path for a Scripticus registry."
|
|
13
|
-
),
|
|
14
|
-
version=__version__,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# Local to the server on purpose: a liveness shape is not part of the
|
|
19
|
-
# package contract, so it doesn't meet the schema/ admission rule (D29).
|
|
20
|
-
class HealthStatus(BaseModel):
|
|
21
|
-
status: Literal["ok"] = "ok"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# Unauthenticated by design: a liveness probe carries nothing worth gating,
|
|
25
|
-
# and the index service stays out of the ACL business anyway (D24). Leave it
|
|
26
|
-
# open even once other endpoints grow auth.
|
|
27
|
-
@app.get("/health")
|
|
28
|
-
def health() -> HealthStatus:
|
|
29
|
-
return HealthStatus()
|
|
File without changes
|
|
File without changes
|