scripticus-server 0.1.0__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.
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scripticus-server
3
- Version: 0.1.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,12 +38,38 @@ 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
- The repository ships a `docker-compose.yml` running the index service as a
42
- single container:
65
+ Server releases publish a Docker image to
66
+ [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
67
+ (tagged with the release version and `latest`). The repository ships a
68
+ `docker-compose.yml` running it as a single container — no checkout
69
+ needed:
43
70
 
44
71
  ```console
72
+ $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
45
73
  $ docker compose up -d
46
74
  $ curl http://localhost:8000/health
47
75
  {"status":"ok"}
@@ -26,12 +26,38 @@ 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
- The repository ships a `docker-compose.yml` running the index service as a
32
- single container:
53
+ Server releases publish a Docker image to
54
+ [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
55
+ (tagged with the release version and `latest`). The repository ships a
56
+ `docker-compose.yml` running it as a single container — no checkout
57
+ needed:
33
58
 
34
59
  ```console
60
+ $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
35
61
  $ docker compose up -d
36
62
  $ curl http://localhost:8000/health
37
63
  {"status":"ok"}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scripticus-server"
3
- version = "0.1.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()