pactown 0.1.4__py3-none-any.whl → 0.1.47__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.
@@ -2,11 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from dataclasses import dataclass, field
6
- from datetime import datetime
7
- from typing import Optional, Any
7
+ from datetime import datetime, timezone
8
8
  from pathlib import Path
9
- import json
9
+ from typing import Any, Optional
10
10
 
11
11
 
12
12
  @dataclass
@@ -15,9 +15,9 @@ class ArtifactVersion:
15
15
  version: str
16
16
  readme_content: str
17
17
  checksum: str
18
- published_at: datetime = field(default_factory=datetime.utcnow)
18
+ published_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
19
19
  metadata: dict[str, Any] = field(default_factory=dict)
20
-
20
+
21
21
  def to_dict(self) -> dict:
22
22
  return {
23
23
  "version": self.version,
@@ -26,7 +26,7 @@ class ArtifactVersion:
26
26
  "published_at": self.published_at.isoformat(),
27
27
  "metadata": self.metadata,
28
28
  }
29
-
29
+
30
30
  @classmethod
31
31
  def from_dict(cls, data: dict) -> "ArtifactVersion":
32
32
  return cls(
@@ -46,24 +46,24 @@ class Artifact:
46
46
  description: str = ""
47
47
  versions: dict[str, ArtifactVersion] = field(default_factory=dict)
48
48
  latest_version: Optional[str] = None
49
- created_at: datetime = field(default_factory=datetime.utcnow)
50
- updated_at: datetime = field(default_factory=datetime.utcnow)
49
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
50
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
51
51
  tags: list[str] = field(default_factory=list)
52
-
52
+
53
53
  @property
54
54
  def full_name(self) -> str:
55
55
  return f"{self.namespace}/{self.name}"
56
-
56
+
57
57
  def add_version(self, version: ArtifactVersion) -> None:
58
58
  self.versions[version.version] = version
59
59
  self.latest_version = version.version
60
- self.updated_at = datetime.utcnow()
61
-
60
+ self.updated_at = datetime.now(timezone.utc)
61
+
62
62
  def get_version(self, version: str = "latest") -> Optional[ArtifactVersion]:
63
63
  if version == "latest" or version == "*":
64
64
  version = self.latest_version
65
65
  return self.versions.get(version)
66
-
66
+
67
67
  def to_dict(self) -> dict:
68
68
  return {
69
69
  "name": self.name,
@@ -75,11 +75,11 @@ class Artifact:
75
75
  "updated_at": self.updated_at.isoformat(),
76
76
  "tags": self.tags,
77
77
  }
78
-
78
+
79
79
  @classmethod
80
80
  def from_dict(cls, data: dict) -> "Artifact":
81
81
  versions = {
82
- k: ArtifactVersion.from_dict(v)
82
+ k: ArtifactVersion.from_dict(v)
83
83
  for k, v in data.get("versions", {}).items()
84
84
  }
85
85
  return cls(
@@ -96,41 +96,41 @@ class Artifact:
96
96
 
97
97
  class RegistryStorage:
98
98
  """File-based storage for registry artifacts."""
99
-
99
+
100
100
  def __init__(self, storage_path: Path):
101
101
  self.storage_path = Path(storage_path)
102
102
  self.storage_path.mkdir(parents=True, exist_ok=True)
103
103
  self._index_path = self.storage_path / "index.json"
104
104
  self._artifacts: dict[str, Artifact] = {}
105
105
  self._load()
106
-
106
+
107
107
  def _load(self) -> None:
108
108
  if self._index_path.exists():
109
109
  with open(self._index_path) as f:
110
110
  data = json.load(f)
111
111
  for full_name, artifact_data in data.get("artifacts", {}).items():
112
112
  self._artifacts[full_name] = Artifact.from_dict(artifact_data)
113
-
113
+
114
114
  def _save(self) -> None:
115
115
  data = {
116
116
  "artifacts": {k: v.to_dict() for k, v in self._artifacts.items()},
117
- "updated_at": datetime.utcnow().isoformat(),
117
+ "updated_at": datetime.now(timezone.utc).isoformat(),
118
118
  }
119
119
  with open(self._index_path, "w") as f:
120
120
  json.dump(data, f, indent=2)
121
-
121
+
122
122
  def get(self, namespace: str, name: str) -> Optional[Artifact]:
123
123
  return self._artifacts.get(f"{namespace}/{name}")
124
-
124
+
125
125
  def list(self, namespace: Optional[str] = None) -> list[Artifact]:
126
126
  if namespace:
127
127
  return [a for a in self._artifacts.values() if a.namespace == namespace]
128
128
  return list(self._artifacts.values())
129
-
129
+
130
130
  def save_artifact(self, artifact: Artifact) -> None:
131
131
  self._artifacts[artifact.full_name] = artifact
132
132
  self._save()
133
-
133
+
134
134
  def delete(self, namespace: str, name: str) -> bool:
135
135
  key = f"{namespace}/{name}"
136
136
  if key in self._artifacts:
@@ -138,12 +138,12 @@ class RegistryStorage:
138
138
  self._save()
139
139
  return True
140
140
  return False
141
-
141
+
142
142
  def search(self, query: str) -> list[Artifact]:
143
143
  query = query.lower()
144
144
  results = []
145
145
  for artifact in self._artifacts.values():
146
- if (query in artifact.name.lower() or
146
+ if (query in artifact.name.lower() or
147
147
  query in artifact.description.lower() or
148
148
  any(query in tag.lower() for tag in artifact.tags)):
149
149
  results.append(artifact)
@@ -49,13 +49,13 @@ class VersionInfo(BaseModel):
49
49
 
50
50
  def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
51
51
  """Create the registry FastAPI application."""
52
-
52
+
53
53
  app = FastAPI(
54
54
  title="Pactown Registry",
55
55
  description="Local artifact registry for markpact modules",
56
56
  version="0.1.0",
57
57
  )
58
-
58
+
59
59
  app.add_middleware(
60
60
  CORSMiddleware,
61
61
  allow_origins=["*"],
@@ -63,13 +63,13 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
63
63
  allow_methods=["*"],
64
64
  allow_headers=["*"],
65
65
  )
66
-
66
+
67
67
  storage = RegistryStorage(Path(storage_path))
68
-
68
+
69
69
  @app.get("/health")
70
70
  def health():
71
71
  return {"status": "ok", "service": "pactown-registry"}
72
-
72
+
73
73
  @app.get("/v1/artifacts", response_model=list[ArtifactInfo])
74
74
  def list_artifacts(
75
75
  namespace: Optional[str] = Query(None, description="Filter by namespace"),
@@ -79,7 +79,7 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
79
79
  artifacts = storage.search(search)
80
80
  else:
81
81
  artifacts = storage.list(namespace)
82
-
82
+
83
83
  return [
84
84
  ArtifactInfo(
85
85
  name=a.name,
@@ -91,13 +91,13 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
91
91
  )
92
92
  for a in artifacts
93
93
  ]
94
-
94
+
95
95
  @app.get("/v1/artifacts/{namespace}/{name}", response_model=ArtifactInfo)
96
96
  def get_artifact(namespace: str, name: str):
97
97
  artifact = storage.get(namespace, name)
98
98
  if not artifact:
99
99
  raise HTTPException(status_code=404, detail="Artifact not found")
100
-
100
+
101
101
  return ArtifactInfo(
102
102
  name=artifact.name,
103
103
  namespace=artifact.namespace,
@@ -106,17 +106,17 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
106
106
  versions=list(artifact.versions.keys()),
107
107
  tags=artifact.tags,
108
108
  )
109
-
109
+
110
110
  @app.get("/v1/artifacts/{namespace}/{name}/{version}", response_model=VersionInfo)
111
111
  def get_version(namespace: str, name: str, version: str):
112
112
  artifact = storage.get(namespace, name)
113
113
  if not artifact:
114
114
  raise HTTPException(status_code=404, detail="Artifact not found")
115
-
115
+
116
116
  ver = artifact.get_version(version)
117
117
  if not ver:
118
118
  raise HTTPException(status_code=404, detail="Version not found")
119
-
119
+
120
120
  return VersionInfo(
121
121
  version=ver.version,
122
122
  readme_content=ver.readme_content,
@@ -124,23 +124,23 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
124
124
  published_at=ver.published_at.isoformat(),
125
125
  metadata=ver.metadata,
126
126
  )
127
-
127
+
128
128
  @app.get("/v1/artifacts/{namespace}/{name}/{version}/readme")
129
129
  def get_readme(namespace: str, name: str, version: str):
130
130
  artifact = storage.get(namespace, name)
131
131
  if not artifact:
132
132
  raise HTTPException(status_code=404, detail="Artifact not found")
133
-
133
+
134
134
  ver = artifact.get_version(version)
135
135
  if not ver:
136
136
  raise HTTPException(status_code=404, detail="Version not found")
137
-
137
+
138
138
  return {"content": ver.readme_content}
139
-
139
+
140
140
  @app.post("/v1/publish", response_model=PublishResponse)
141
141
  def publish(req: PublishRequest):
142
142
  checksum = hashlib.sha256(req.readme_content.encode()).hexdigest()
143
-
143
+
144
144
  artifact = storage.get(req.namespace, req.name)
145
145
  if not artifact:
146
146
  artifact = Artifact(
@@ -149,40 +149,40 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
149
149
  description=req.description,
150
150
  tags=req.tags,
151
151
  )
152
-
152
+
153
153
  version = ArtifactVersion(
154
154
  version=req.version,
155
155
  readme_content=req.readme_content,
156
156
  checksum=checksum,
157
157
  metadata=req.metadata,
158
158
  )
159
-
159
+
160
160
  artifact.add_version(version)
161
161
  if req.description:
162
162
  artifact.description = req.description
163
163
  if req.tags:
164
164
  artifact.tags = list(set(artifact.tags + req.tags))
165
-
165
+
166
166
  storage.save_artifact(artifact)
167
-
167
+
168
168
  return PublishResponse(
169
169
  success=True,
170
170
  artifact=artifact.full_name,
171
171
  version=req.version,
172
172
  checksum=checksum,
173
173
  )
174
-
174
+
175
175
  @app.delete("/v1/artifacts/{namespace}/{name}")
176
176
  def delete_artifact(namespace: str, name: str):
177
177
  if storage.delete(namespace, name):
178
178
  return {"success": True, "message": f"Deleted {namespace}/{name}"}
179
179
  raise HTTPException(status_code=404, detail="Artifact not found")
180
-
180
+
181
181
  @app.get("/v1/namespaces")
182
182
  def list_namespaces():
183
183
  namespaces = set(a.namespace for a in storage.list())
184
184
  return {"namespaces": sorted(namespaces)}
185
-
185
+
186
186
  return app
187
187
 
188
188
 
@@ -193,7 +193,7 @@ def create_app(storage_path: str = "./.pactown-registry") -> FastAPI:
193
193
  @click.option("--reload", is_flag=True, help="Enable auto-reload")
194
194
  def main(host: str, port: int, storage: str, reload: bool):
195
195
  """Start the pactown registry server."""
196
- app = create_app(storage)
196
+ create_app(storage)
197
197
  uvicorn.run(
198
198
  "pactown.registry.server:create_app",
199
199
  host=host,
pactown/resolver.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """Dependency resolver for pactown ecosystems."""
2
2
 
3
+ from collections import deque
3
4
  from dataclasses import dataclass
4
5
  from typing import Optional
5
- from collections import deque
6
6
 
7
7
  from .config import EcosystemConfig, ServiceConfig
8
8
 
@@ -19,12 +19,12 @@ class ResolvedDependency:
19
19
 
20
20
  class DependencyResolver:
21
21
  """Resolves dependencies between services in an ecosystem."""
22
-
22
+
23
23
  def __init__(self, config: EcosystemConfig):
24
24
  self.config = config
25
25
  self._graph: dict[str, list[str]] = {}
26
26
  self._build_graph()
27
-
27
+
28
28
  def _build_graph(self) -> None:
29
29
  """Build dependency graph from configuration."""
30
30
  for name, service in self.config.services.items():
@@ -32,7 +32,7 @@ class DependencyResolver:
32
32
  for dep in service.depends_on:
33
33
  if dep.name in self.config.services:
34
34
  self._graph[name].append(dep.name)
35
-
35
+
36
36
  def get_startup_order(self) -> list[str]:
37
37
  """
38
38
  Get services in topological order for startup.
@@ -40,46 +40,46 @@ class DependencyResolver:
40
40
  """
41
41
  # in_degree[X] = number of dependencies X has
42
42
  in_degree = {name: len(deps) for name, deps in self._graph.items()}
43
-
43
+
44
44
  # Start with services that have no dependencies
45
45
  queue = deque([name for name, degree in in_degree.items() if degree == 0])
46
46
  order = []
47
-
47
+
48
48
  while queue:
49
49
  current = queue.popleft()
50
50
  order.append(current)
51
-
51
+
52
52
  # For each service that depends on current, decrease its in_degree
53
53
  for name, deps in self._graph.items():
54
54
  if current in deps:
55
55
  in_degree[name] -= 1
56
56
  if in_degree[name] == 0:
57
57
  queue.append(name)
58
-
58
+
59
59
  if len(order) != len(self._graph):
60
60
  missing = set(self._graph.keys()) - set(order)
61
61
  raise ValueError(f"Circular dependency detected involving: {missing}")
62
-
62
+
63
63
  return order
64
-
64
+
65
65
  def get_shutdown_order(self) -> list[str]:
66
66
  """Get services in reverse order for shutdown."""
67
67
  return list(reversed(self.get_startup_order()))
68
-
68
+
69
69
  def resolve_service_deps(self, service_name: str) -> list[ResolvedDependency]:
70
70
  """Resolve all dependencies for a service."""
71
71
  if service_name not in self.config.services:
72
72
  raise ValueError(f"Unknown service: {service_name}")
73
-
73
+
74
74
  service = self.config.services[service_name]
75
75
  resolved = []
76
-
76
+
77
77
  for dep in service.depends_on:
78
78
  if dep.name in self.config.services:
79
79
  dep_service = self.config.services[dep.name]
80
80
  endpoint = dep.endpoint or f"http://localhost:{dep_service.port}"
81
81
  env_var = dep.env_var or f"{dep.name.upper().replace('-', '_')}_URL"
82
-
82
+
83
83
  resolved.append(ResolvedDependency(
84
84
  name=dep.name,
85
85
  version=dep.version,
@@ -90,43 +90,43 @@ class DependencyResolver:
90
90
  else:
91
91
  endpoint = dep.endpoint or f"http://localhost:8800/v1/{dep.name}"
92
92
  env_var = dep.env_var or f"{dep.name.upper().replace('-', '_')}_URL"
93
-
93
+
94
94
  resolved.append(ResolvedDependency(
95
95
  name=dep.name,
96
96
  version=dep.version,
97
97
  endpoint=endpoint,
98
98
  env_var=env_var,
99
99
  ))
100
-
100
+
101
101
  return resolved
102
-
102
+
103
103
  def get_environment(self, service_name: str) -> dict[str, str]:
104
104
  """Get environment variables for a service including dependency endpoints."""
105
105
  if service_name not in self.config.services:
106
106
  raise ValueError(f"Unknown service: {service_name}")
107
-
107
+
108
108
  service = self.config.services[service_name]
109
109
  env = dict(service.env)
110
-
110
+
111
111
  for dep in self.resolve_service_deps(service_name):
112
112
  env[dep.env_var] = dep.endpoint
113
-
113
+
114
114
  env["PACTOWN_SERVICE_NAME"] = service_name
115
115
  env["PACTOWN_ECOSYSTEM"] = self.config.name
116
116
  if service.port:
117
117
  env["MARKPACT_PORT"] = str(service.port)
118
-
118
+
119
119
  return env
120
-
120
+
121
121
  def validate(self) -> list[str]:
122
122
  """Validate the dependency graph and return any issues."""
123
123
  issues = []
124
-
124
+
125
125
  try:
126
126
  self.get_startup_order()
127
127
  except ValueError as e:
128
128
  issues.append(str(e))
129
-
129
+
130
130
  for name, service in self.config.services.items():
131
131
  for dep in service.depends_on:
132
132
  if dep.name not in self.config.services:
@@ -135,26 +135,26 @@ class DependencyResolver:
135
135
  f"Service '{name}' depends on '{dep.name}' which is not "
136
136
  f"defined locally and no registry is configured"
137
137
  )
138
-
138
+
139
139
  return issues
140
-
140
+
141
141
  def print_graph(self) -> str:
142
142
  """Return ASCII representation of dependency graph."""
143
143
  lines = [f"Ecosystem: {self.config.name}", ""]
144
-
144
+
145
145
  try:
146
146
  order = self.get_startup_order()
147
147
  except ValueError:
148
148
  order = list(self._graph.keys())
149
-
149
+
150
150
  for name in order:
151
151
  service = self.config.services[name]
152
152
  deps = [d.name for d in service.depends_on]
153
153
  port = f":{service.port}" if service.port else ""
154
-
154
+
155
155
  if deps:
156
156
  lines.append(f" [{name}{port}] → {', '.join(deps)}")
157
157
  else:
158
158
  lines.append(f" [{name}{port}] (no deps)")
159
-
159
+
160
160
  return "\n".join(lines)