changex-api 0.1.0__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.
- changex_api/__init__.py +29 -0
- changex_api/__main__.py +77 -0
- changex_api/app.py +405 -0
- changex_api/models.py +235 -0
- changex_api-0.1.0.dist-info/METADATA +75 -0
- changex_api-0.1.0.dist-info/RECORD +9 -0
- changex_api-0.1.0.dist-info/WHEEL +4 -0
- changex_api-0.1.0.dist-info/entry_points.txt +2 -0
- changex_api-0.1.0.dist-info/licenses/LICENSE +21 -0
changex_api/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""ChangeX HTTP/REST API: the changex-core spine, re-exposed over plain HTTP.
|
|
2
|
+
|
|
3
|
+
This package wraps :mod:`changex_core` (reusing the MCP tool semantics from
|
|
4
|
+
:mod:`changex_mcp.tools`) in a FastAPI app so ANY caller can drive tracked
|
|
5
|
+
editing over HTTP — a local/offline model with no function-calling, a ``curl``
|
|
6
|
+
script, or a ChatGPT custom GPT whose Action imports the auto-generated
|
|
7
|
+
``/openapi.json``.
|
|
8
|
+
|
|
9
|
+
Public surface (used by the entry point and tests):
|
|
10
|
+
|
|
11
|
+
App
|
|
12
|
+
``app.app`` — the configured :class:`fastapi.FastAPI` instance;
|
|
13
|
+
``app.create_app()`` — a factory that builds a fresh app with its own
|
|
14
|
+
in-process session store.
|
|
15
|
+
|
|
16
|
+
Runner
|
|
17
|
+
``__main__.main`` — the ``changex-api`` console script / ``python -m
|
|
18
|
+
changex_api`` entry point (launches uvicorn, 127.0.0.1 by default).
|
|
19
|
+
|
|
20
|
+
Models
|
|
21
|
+
``models`` — the Pydantic request/response models that shape the OpenAPI
|
|
22
|
+
schema consumed by ChatGPT Actions and other clients.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = ["__version__"]
|
changex_api/__main__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Run the ChangeX API with uvicorn: ``changex-api`` / ``python -m changex_api``.
|
|
2
|
+
|
|
3
|
+
Binds ``127.0.0.1`` by default (the local, trustworthy path that needs no token).
|
|
4
|
+
A non-local bind (any host other than ``127.0.0.1`` / ``localhost`` / ``::1``) is
|
|
5
|
+
*refused* unless ``CHANGEX_API_TOKEN`` is set, so the surface is never exposed to
|
|
6
|
+
a network without bearer-token auth — a fail-closed default rather than a footgun.
|
|
7
|
+
|
|
8
|
+
Flags / env:
|
|
9
|
+
|
|
10
|
+
* ``--host`` / ``CHANGEX_API_HOST`` (default ``127.0.0.1``)
|
|
11
|
+
* ``--port`` / ``CHANGEX_API_PORT`` (default ``8000``)
|
|
12
|
+
* ``--reload`` for development auto-reload
|
|
13
|
+
* ``CHANGEX_API_TOKEN`` — when set, every non-health route requires
|
|
14
|
+
``Authorization: Bearer <token>``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import uvicorn
|
|
24
|
+
|
|
25
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
26
|
+
DEFAULT_PORT = 8000
|
|
27
|
+
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
28
|
+
_TOKEN_ENV = "CHANGEX_API_TOKEN"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="changex-api",
|
|
34
|
+
description="Serve the ChangeX HTTP/REST API (FastAPI + uvicorn).",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--host",
|
|
38
|
+
default=os.environ.get("CHANGEX_API_HOST", DEFAULT_HOST),
|
|
39
|
+
help="Bind host (default 127.0.0.1). Non-local hosts require CHANGEX_API_TOKEN.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--port",
|
|
43
|
+
type=int,
|
|
44
|
+
default=int(os.environ.get("CHANGEX_API_PORT", DEFAULT_PORT)),
|
|
45
|
+
help="Bind port (default 8000).",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--reload", action="store_true", help="Enable uvicorn auto-reload (development)."
|
|
49
|
+
)
|
|
50
|
+
return parser.parse_args(argv)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: list[str] | None = None) -> int:
|
|
54
|
+
"""Console-script / ``python -m changex_api`` entry point.
|
|
55
|
+
|
|
56
|
+
Returns a process exit code (``0`` on a clean shutdown, ``2`` if a non-local
|
|
57
|
+
bind was requested without a token).
|
|
58
|
+
"""
|
|
59
|
+
args = _parse_args(argv)
|
|
60
|
+
host = str(args.host)
|
|
61
|
+
if host not in _LOCAL_HOSTS and not os.environ.get(_TOKEN_ENV):
|
|
62
|
+
sys.stderr.write(
|
|
63
|
+
f"refusing to bind non-local host {host!r} without {_TOKEN_ENV}; "
|
|
64
|
+
f"set {_TOKEN_ENV}=<secret> to enable bearer-token auth, or bind 127.0.0.1.\n"
|
|
65
|
+
)
|
|
66
|
+
return 2
|
|
67
|
+
uvicorn.run(
|
|
68
|
+
"changex_api.app:app",
|
|
69
|
+
host=host,
|
|
70
|
+
port=int(args.port),
|
|
71
|
+
reload=bool(args.reload),
|
|
72
|
+
)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__": # pragma: no cover
|
|
77
|
+
raise SystemExit(main())
|
changex_api/app.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""The ChangeX HTTP/REST API — a thin FastAPI wrapper over ``changex_core``.
|
|
2
|
+
|
|
3
|
+
Why this exists: ChangeX already exposes its tracked-editing spine over MCP
|
|
4
|
+
(:mod:`changex_mcp`). This package re-exposes the *same* semantics over plain
|
|
5
|
+
HTTP so ANY caller can use it — a local/offline model with no function-calling, a
|
|
6
|
+
shell script with ``curl``, or a ChatGPT custom GPT whose Action consumes the
|
|
7
|
+
auto-generated ``/openapi.json``. The endpoint set mirrors the MCP tools 1:1.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
|
|
11
|
+
* **Reuse, not reimplementation.** The tracked-editing tools delegate to
|
|
12
|
+
:mod:`changex_mcp.tools` against an in-process :class:`SessionStore`, so the
|
|
13
|
+
boundary enforcement (before-substring validation, oversized-op rejection) and
|
|
14
|
+
the structured error codes are byte-for-byte the same as MCP. The passive
|
|
15
|
+
``/open`` + ``/seal`` and ``/report`` endpoints call the core directly.
|
|
16
|
+
* **Path-sanitized.** Every caller-supplied path goes through
|
|
17
|
+
:func:`changex_core.paths.safe_path` (the same guard the core uses) before any
|
|
18
|
+
I/O, rejecting directory traversal / NUL bytes / wrong suffixes.
|
|
19
|
+
* **Local by default.** ``create_app`` binds nothing itself; the runner
|
|
20
|
+
(:mod:`changex_api.__main__`) defaults to ``127.0.0.1``. When a token is set via
|
|
21
|
+
``CHANGEX_API_TOKEN`` (required for any non-local bind), every route except the
|
|
22
|
+
liveness probe demands a matching ``Authorization: Bearer <token>`` header.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
|
|
31
|
+
from fastapi.responses import HTMLResponse
|
|
32
|
+
|
|
33
|
+
from changex_core import __version__ as _CORE_VERSION
|
|
34
|
+
from changex_core.journal.journal import Journal
|
|
35
|
+
from changex_core.paths import UnsafePathError, safe_path
|
|
36
|
+
from changex_core.passive import open_passive, seal_passive
|
|
37
|
+
from changex_core.render.html import render_html, render_markdown
|
|
38
|
+
|
|
39
|
+
from changex_mcp import tools
|
|
40
|
+
from changex_mcp.session import SessionError, SessionStore
|
|
41
|
+
|
|
42
|
+
from changex_api import __version__ as _API_VERSION
|
|
43
|
+
from changex_api.models import (
|
|
44
|
+
ChangesResponse,
|
|
45
|
+
EditRequest,
|
|
46
|
+
EditResponse,
|
|
47
|
+
HealthResponse,
|
|
48
|
+
OpenTrackedRequest,
|
|
49
|
+
OpenTrackedResponse,
|
|
50
|
+
OutlineResponse,
|
|
51
|
+
PassiveOpenRequest,
|
|
52
|
+
PassiveOpenResponse,
|
|
53
|
+
PassiveSealRequest,
|
|
54
|
+
PassiveSealResponse,
|
|
55
|
+
ReportResponse,
|
|
56
|
+
SaveRequest,
|
|
57
|
+
SaveResponse,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
#: Environment variable holding the bearer token. When set, all non-health routes
|
|
61
|
+
#: require ``Authorization: Bearer <CHANGEX_API_TOKEN>``. Leave unset only for a
|
|
62
|
+
#: trusted local (127.0.0.1) bind.
|
|
63
|
+
TOKEN_ENV = "CHANGEX_API_TOKEN"
|
|
64
|
+
|
|
65
|
+
# Starlette renamed 422 to *_CONTENT; prefer the new name, fall back for older
|
|
66
|
+
# pins so the package works across the supported FastAPI/Starlette range. (The
|
|
67
|
+
# deprecated alias is only touched when the new constant is absent, so newer
|
|
68
|
+
# Starlette never triggers its DeprecationWarning.)
|
|
69
|
+
if hasattr(status, "HTTP_422_UNPROCESSABLE_CONTENT"):
|
|
70
|
+
HTTP_422 = status.HTTP_422_UNPROCESSABLE_CONTENT
|
|
71
|
+
else: # pragma: no cover - depends on the installed Starlette version
|
|
72
|
+
HTTP_422 = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
73
|
+
|
|
74
|
+
API_TITLE = "ChangeX API"
|
|
75
|
+
API_DESCRIPTION = (
|
|
76
|
+
"Provenance-first tracked editing for Word documents, over HTTP. Open a .docx "
|
|
77
|
+
"for tracked editing, discover node ids via the outline, make the SMALLEST "
|
|
78
|
+
"possible intent-named edit per call (always passing the exact existing text in "
|
|
79
|
+
"`before` — blind overwrites are refused), then save a native Word "
|
|
80
|
+
"accept/reject revisions file plus a hash-chained .changex provenance journal. "
|
|
81
|
+
"A passive (no-tool-calling) /open + /seal path serves offline models. This "
|
|
82
|
+
"schema is consumable directly as a ChatGPT custom GPT Action."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _require_token(authorization: str | None = Header(default=None)) -> None:
|
|
87
|
+
"""Enforce bearer-token auth iff ``CHANGEX_API_TOKEN`` is set.
|
|
88
|
+
|
|
89
|
+
When the env var is unset (the local-only default) this is a no-op so the
|
|
90
|
+
127.0.0.1 developer flow needs no header. When it is set, every guarded route
|
|
91
|
+
requires ``Authorization: Bearer <token>`` and rejects anything else with 401.
|
|
92
|
+
"""
|
|
93
|
+
expected = os.environ.get(TOKEN_ENV)
|
|
94
|
+
if not expected:
|
|
95
|
+
return
|
|
96
|
+
if not authorization or not authorization.startswith("Bearer "):
|
|
97
|
+
raise HTTPException(
|
|
98
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
99
|
+
detail="missing bearer token",
|
|
100
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
101
|
+
)
|
|
102
|
+
presented = authorization[len("Bearer ") :].strip()
|
|
103
|
+
if presented != expected:
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
106
|
+
detail="invalid bearer token",
|
|
107
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _tool_error_http(exc: tools.ToolError) -> HTTPException:
|
|
112
|
+
"""Map an MCP ToolError to an HTTP 4xx carrying its structured payload.
|
|
113
|
+
|
|
114
|
+
``already_open`` is a conflict (409); ``open_failed`` is a bad request (400);
|
|
115
|
+
everything else (split_required, before_mismatch, node_not_found, …) is a 422
|
|
116
|
+
unprocessable entity — the model should read the detail and adjust the op.
|
|
117
|
+
"""
|
|
118
|
+
if exc.code == "already_open":
|
|
119
|
+
code = status.HTTP_409_CONFLICT
|
|
120
|
+
elif exc.code in ("open_failed", "bad_format"):
|
|
121
|
+
code = status.HTTP_400_BAD_REQUEST
|
|
122
|
+
else:
|
|
123
|
+
code = HTTP_422
|
|
124
|
+
return HTTPException(status_code=code, detail=exc.to_dict())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _value_error_http(exc: Exception) -> HTTPException:
|
|
128
|
+
"""Map a validation / unsafe-path error to a 400 with a structured payload."""
|
|
129
|
+
code = "unsafe_path" if isinstance(exc, UnsafePathError) else "invalid_argument"
|
|
130
|
+
return HTTPException(
|
|
131
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
132
|
+
detail={"error": code, "detail": str(exc)},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def create_app() -> FastAPI:
|
|
137
|
+
"""Build and return the configured FastAPI application.
|
|
138
|
+
|
|
139
|
+
A single in-process :class:`SessionStore` backs the tracked-editing routes
|
|
140
|
+
(one process, single-session-per-handle — same model as the MCP server). The
|
|
141
|
+
returned app auto-serves its OpenAPI schema at ``/openapi.json``; that schema
|
|
142
|
+
is exactly what a ChatGPT custom GPT Action imports.
|
|
143
|
+
"""
|
|
144
|
+
store = SessionStore()
|
|
145
|
+
app = FastAPI(
|
|
146
|
+
title=API_TITLE,
|
|
147
|
+
description=API_DESCRIPTION,
|
|
148
|
+
version=_API_VERSION,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
guard = [Depends(_require_token)]
|
|
152
|
+
|
|
153
|
+
# -- liveness (unauthenticated) -------------------------------------------
|
|
154
|
+
|
|
155
|
+
@app.get("/healthz", response_model=HealthResponse, tags=["meta"], operation_id="healthz")
|
|
156
|
+
def healthz() -> HealthResponse:
|
|
157
|
+
"""Liveness probe; never requires auth."""
|
|
158
|
+
return HealthResponse(version=_API_VERSION)
|
|
159
|
+
|
|
160
|
+
# -- tracked editing (mirrors the MCP tools) ------------------------------
|
|
161
|
+
|
|
162
|
+
@app.post(
|
|
163
|
+
"/sessions",
|
|
164
|
+
response_model=OpenTrackedResponse,
|
|
165
|
+
tags=["tracked"],
|
|
166
|
+
dependencies=guard,
|
|
167
|
+
operation_id="openTracked",
|
|
168
|
+
)
|
|
169
|
+
def open_session(body: OpenTrackedRequest) -> Any:
|
|
170
|
+
"""Open a .docx for tracked editing and return a session handle + summary."""
|
|
171
|
+
agent_ctx = body.agent_context.model_dump() if body.agent_context else None
|
|
172
|
+
try:
|
|
173
|
+
return tools.open_tracked(
|
|
174
|
+
store, path=body.path, agent_context=agent_ctx, author=body.author
|
|
175
|
+
)
|
|
176
|
+
except tools.ToolError as exc:
|
|
177
|
+
raise _tool_error_http(exc) from exc
|
|
178
|
+
|
|
179
|
+
@app.get(
|
|
180
|
+
"/sessions/{handle}/outline",
|
|
181
|
+
response_model=OutlineResponse,
|
|
182
|
+
tags=["tracked"],
|
|
183
|
+
dependencies=guard,
|
|
184
|
+
operation_id="getOutline",
|
|
185
|
+
)
|
|
186
|
+
def get_outline(
|
|
187
|
+
handle: str,
|
|
188
|
+
cursor: str | None = Query(default=None, description="Opaque pagination cursor."),
|
|
189
|
+
limit: int = Query(default=100, ge=1, le=500, description="Max entries per page."),
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""Return a bounded, paginated outline of the document's paragraphs."""
|
|
192
|
+
try:
|
|
193
|
+
return tools.get_outline(store, handle=handle, cursor=cursor, limit=limit)
|
|
194
|
+
except tools.ToolError as exc:
|
|
195
|
+
raise _tool_error_http(exc) from exc
|
|
196
|
+
except SessionError as exc:
|
|
197
|
+
raise _unknown_handle_http(exc) from exc
|
|
198
|
+
except (ValueError, KeyError) as exc:
|
|
199
|
+
raise _value_error_http(exc) from exc
|
|
200
|
+
|
|
201
|
+
@app.post(
|
|
202
|
+
"/sessions/{handle}/edit",
|
|
203
|
+
response_model=EditResponse,
|
|
204
|
+
tags=["tracked"],
|
|
205
|
+
dependencies=guard,
|
|
206
|
+
operation_id="editSession",
|
|
207
|
+
)
|
|
208
|
+
def edit(handle: str, body: EditRequest) -> Any:
|
|
209
|
+
"""Apply ONE small, intent-named tracked edit to a node and journal it."""
|
|
210
|
+
try:
|
|
211
|
+
return tools.edit(
|
|
212
|
+
store,
|
|
213
|
+
handle=handle,
|
|
214
|
+
op=body.op,
|
|
215
|
+
node_id=body.node_id,
|
|
216
|
+
before=body.before,
|
|
217
|
+
after=body.after,
|
|
218
|
+
anchor=body.anchor,
|
|
219
|
+
text=body.text,
|
|
220
|
+
style=body.style,
|
|
221
|
+
rationale=body.rationale,
|
|
222
|
+
prompt=body.prompt,
|
|
223
|
+
turn_id=body.turn_id,
|
|
224
|
+
)
|
|
225
|
+
except tools.ToolError as exc:
|
|
226
|
+
raise _tool_error_http(exc) from exc
|
|
227
|
+
except SessionError as exc:
|
|
228
|
+
raise _unknown_handle_http(exc) from exc
|
|
229
|
+
except (ValueError, KeyError) as exc:
|
|
230
|
+
raise _value_error_http(exc) from exc
|
|
231
|
+
|
|
232
|
+
@app.post(
|
|
233
|
+
"/sessions/{handle}/save",
|
|
234
|
+
response_model=SaveResponse,
|
|
235
|
+
tags=["tracked"],
|
|
236
|
+
dependencies=guard,
|
|
237
|
+
operation_id="saveSession",
|
|
238
|
+
)
|
|
239
|
+
def save(handle: str, body: SaveRequest) -> Any:
|
|
240
|
+
"""Save the native-revisions .docx and report the tracked + .changex paths."""
|
|
241
|
+
try:
|
|
242
|
+
return tools.save_tracked(store, handle=handle, out=body.out)
|
|
243
|
+
except tools.ToolError as exc:
|
|
244
|
+
raise _tool_error_http(exc) from exc
|
|
245
|
+
except SessionError as exc:
|
|
246
|
+
raise _unknown_handle_http(exc) from exc
|
|
247
|
+
except (ValueError, KeyError) as exc:
|
|
248
|
+
raise _value_error_http(exc) from exc
|
|
249
|
+
|
|
250
|
+
@app.get(
|
|
251
|
+
"/sessions/{handle}/changes",
|
|
252
|
+
response_model=ChangesResponse,
|
|
253
|
+
tags=["tracked"],
|
|
254
|
+
dependencies=guard,
|
|
255
|
+
operation_id="getChanges",
|
|
256
|
+
)
|
|
257
|
+
def get_changes(handle: str) -> Any:
|
|
258
|
+
"""Return the structured provenance journal (active, non-reverted events)."""
|
|
259
|
+
try:
|
|
260
|
+
return tools.get_changes(store, handle=handle)
|
|
261
|
+
except tools.ToolError as exc:
|
|
262
|
+
raise _tool_error_http(exc) from exc
|
|
263
|
+
except SessionError as exc:
|
|
264
|
+
raise _unknown_handle_http(exc) from exc
|
|
265
|
+
except (ValueError, KeyError) as exc:
|
|
266
|
+
raise _value_error_http(exc) from exc
|
|
267
|
+
|
|
268
|
+
# -- passive ("native to any model") open / seal --------------------------
|
|
269
|
+
|
|
270
|
+
@app.post(
|
|
271
|
+
"/open",
|
|
272
|
+
response_model=PassiveOpenResponse,
|
|
273
|
+
tags=["passive"],
|
|
274
|
+
dependencies=guard,
|
|
275
|
+
operation_id="passiveOpen",
|
|
276
|
+
)
|
|
277
|
+
def passive_open(body: PassiveOpenRequest) -> PassiveOpenResponse:
|
|
278
|
+
"""Snapshot a docx and write a pending passive journal (no tool calls).
|
|
279
|
+
|
|
280
|
+
Any tool may then edit the docx freely; call /seal to capture the delta.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
result = open_passive(body.docx, body.changex)
|
|
284
|
+
except (UnsafePathError, ValueError, OSError) as exc:
|
|
285
|
+
raise _value_error_http(exc) from exc
|
|
286
|
+
return PassiveOpenResponse(
|
|
287
|
+
changex_path=str(result.changex_path),
|
|
288
|
+
session_id=result.session_id,
|
|
289
|
+
baseline_sha256=result.baseline.sha256,
|
|
290
|
+
paragraphs=result.paragraphs,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@app.post(
|
|
294
|
+
"/seal",
|
|
295
|
+
response_model=PassiveSealResponse,
|
|
296
|
+
tags=["passive"],
|
|
297
|
+
dependencies=guard,
|
|
298
|
+
operation_id="passiveSeal",
|
|
299
|
+
)
|
|
300
|
+
def passive_seal(body: PassiveSealRequest) -> PassiveSealResponse:
|
|
301
|
+
"""Diff the edited docx vs the stored baseline and append passive ops.
|
|
302
|
+
|
|
303
|
+
Returns honest, *degraded* counts: passive ops are observed net textual
|
|
304
|
+
deltas, not true provenance (agent/vendor/prompt are null).
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
result = seal_passive(body.docx, body.changex)
|
|
308
|
+
except (UnsafePathError, ValueError, OSError) as exc:
|
|
309
|
+
raise _value_error_http(exc) from exc
|
|
310
|
+
return PassiveSealResponse(
|
|
311
|
+
changex_path=str(result.changex_path),
|
|
312
|
+
appended=result.appended,
|
|
313
|
+
replaced=result.replaced,
|
|
314
|
+
inserted=result.inserted,
|
|
315
|
+
deleted=result.deleted,
|
|
316
|
+
style_changed=result.style_changed,
|
|
317
|
+
baseline_unchanged=result.baseline_unchanged,
|
|
318
|
+
degraded=result.degraded,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# -- report (HTML/markdown redline) ---------------------------------------
|
|
322
|
+
|
|
323
|
+
@app.post(
|
|
324
|
+
"/report",
|
|
325
|
+
tags=["review"],
|
|
326
|
+
dependencies=guard,
|
|
327
|
+
operation_id="renderReport",
|
|
328
|
+
responses={200: {"content": {"text/html": {}, "application/json": {}}}},
|
|
329
|
+
)
|
|
330
|
+
def report(
|
|
331
|
+
handle: str | None = Query(
|
|
332
|
+
default=None,
|
|
333
|
+
description="An open tracked-session handle to render from.",
|
|
334
|
+
),
|
|
335
|
+
changex: str | None = Query(
|
|
336
|
+
default=None,
|
|
337
|
+
description="OR a path to a .changex journal to render from (passive flow).",
|
|
338
|
+
),
|
|
339
|
+
fmt: str = Query(default="html", description="'html' (default) or 'markdown'."),
|
|
340
|
+
raw: bool = Query(
|
|
341
|
+
default=False,
|
|
342
|
+
description="If true, return the report as text/html instead of JSON.",
|
|
343
|
+
),
|
|
344
|
+
) -> Any:
|
|
345
|
+
"""Render an HTML/markdown redline of a session's or journal's changes.
|
|
346
|
+
|
|
347
|
+
Supply either an open ``handle`` (tracked flow) or a ``changex`` journal
|
|
348
|
+
path (passive flow). With ``raw=true`` and ``fmt=html`` the response is a
|
|
349
|
+
ready-to-display ``text/html`` page; otherwise a JSON ``{format, report}``.
|
|
350
|
+
"""
|
|
351
|
+
fmt_norm = (fmt or "html").lower()
|
|
352
|
+
if fmt_norm not in ("html", "markdown"):
|
|
353
|
+
raise HTTPException(
|
|
354
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
355
|
+
detail={"error": "bad_format", "detail": "fmt must be 'html' or 'markdown'."},
|
|
356
|
+
)
|
|
357
|
+
if handle:
|
|
358
|
+
try:
|
|
359
|
+
payload = tools.render_review(store, handle=handle, fmt=fmt_norm)
|
|
360
|
+
except tools.ToolError as exc:
|
|
361
|
+
raise _tool_error_http(exc) from exc
|
|
362
|
+
except SessionError as exc:
|
|
363
|
+
raise _unknown_handle_http(exc) from exc
|
|
364
|
+
except (ValueError, KeyError) as exc:
|
|
365
|
+
raise _value_error_http(exc) from exc
|
|
366
|
+
rendered = str(payload["report"])
|
|
367
|
+
elif changex:
|
|
368
|
+
rendered = _render_from_journal(changex, fmt_norm)
|
|
369
|
+
else:
|
|
370
|
+
raise HTTPException(
|
|
371
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
372
|
+
detail={"error": "missing_argument", "detail": "pass either handle or changex."},
|
|
373
|
+
)
|
|
374
|
+
if raw and fmt_norm == "html":
|
|
375
|
+
return HTMLResponse(content=rendered)
|
|
376
|
+
return ReportResponse(format=fmt_norm, report=rendered)
|
|
377
|
+
|
|
378
|
+
return app
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _render_from_journal(changex: str, fmt: str) -> str:
|
|
382
|
+
"""Render a redline directly from a ``.changex`` journal path (passive flow)."""
|
|
383
|
+
try:
|
|
384
|
+
path = safe_path(changex, must_exist=True, allow_suffixes=(".changex", ".jsonl"))
|
|
385
|
+
journal = Journal.open(str(path))
|
|
386
|
+
except (UnsafePathError, ValueError, OSError) as exc:
|
|
387
|
+
raise _value_error_http(exc) from exc
|
|
388
|
+
events = journal.active_events()
|
|
389
|
+
return render_markdown(events) if fmt == "markdown" else render_html(events)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _unknown_handle_http(exc: SessionError) -> HTTPException:
|
|
393
|
+
"""Map an unknown-handle error to a 404 so 'bad handle' != 'bad input'.
|
|
394
|
+
|
|
395
|
+
``SessionStore.get`` raises :class:`SessionError` for an unknown handle; we
|
|
396
|
+
surface that as 404 with a structured payload distinct from a 400 input error.
|
|
397
|
+
"""
|
|
398
|
+
return HTTPException(
|
|
399
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
400
|
+
detail={"error": "unknown_handle", "detail": str(exc)},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
#: Module-level app for ``uvicorn changex_api.app:app`` and the TestClient.
|
|
405
|
+
app = create_app()
|
changex_api/models.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Pydantic request/response models for the ChangeX REST API.
|
|
2
|
+
|
|
3
|
+
These are deliberately explicit (typed fields + descriptions + examples) because
|
|
4
|
+
they ARE the OpenAPI schema that a ChatGPT custom GPT Action consumes at
|
|
5
|
+
``/openapi.json``. Good field descriptions here become good tool affordances for
|
|
6
|
+
any model calling the API. The semantics mirror the MCP tools 1:1
|
|
7
|
+
(:mod:`changex_mcp.tools`): an ``op`` discriminator selects one small intent and
|
|
8
|
+
its required payload, the exact ``before`` substring is always carried so the
|
|
9
|
+
adapter can refuse blind overwrites, and an oversized op is rejected so the model
|
|
10
|
+
splits the change.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Literal, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
# The intent discriminators accepted by POST /sessions/{id}/edit, mirroring
|
|
20
|
+
# ``changex_mcp.tools.EDIT_OPS``.
|
|
21
|
+
EditOp = Literal["replace_text", "insert_text_after", "delete_text", "set_paragraph_style"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentContext(BaseModel):
|
|
25
|
+
"""Once-per-session declared identity (captured at open, never per-edit)."""
|
|
26
|
+
|
|
27
|
+
model: Optional[str] = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description="Your model id, e.g. 'claude-opus-4-8' or 'gpt-4o'. Recorded "
|
|
30
|
+
"once for the whole session and used as the Word revision author.",
|
|
31
|
+
)
|
|
32
|
+
vendor: Optional[str] = Field(
|
|
33
|
+
default=None, description="Your vendor, e.g. 'anthropic', 'openai', 'google'."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -- open / sessions ----------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenTrackedRequest(BaseModel):
|
|
41
|
+
"""Body for POST /sessions — open a .docx for tracked editing."""
|
|
42
|
+
|
|
43
|
+
path: str = Field(
|
|
44
|
+
...,
|
|
45
|
+
description="Absolute server-side path to the .docx to open for tracked editing.",
|
|
46
|
+
examples=["/data/contract.docx"],
|
|
47
|
+
)
|
|
48
|
+
agent_context: Optional[AgentContext] = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
description="Optional once-per-session declared identity {model, vendor}.",
|
|
51
|
+
)
|
|
52
|
+
author: Optional[str] = Field(
|
|
53
|
+
default=None,
|
|
54
|
+
description="Override the Word revision author (defaults to the model id).",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OpenTrackedResponse(BaseModel):
|
|
59
|
+
"""Session handle + summary returned by POST /sessions."""
|
|
60
|
+
|
|
61
|
+
handle: str = Field(..., description="Opaque session handle; pass it to every other call.")
|
|
62
|
+
session_id: str = Field(..., description="Stable journal session id (provenance identity).")
|
|
63
|
+
baseline_sha256: str = Field(..., description="SHA-256 of the opened baseline bytes.")
|
|
64
|
+
summary: dict[str, Any] = Field(
|
|
65
|
+
..., description="{filename, paragraphs, agent, vendor, changex_path}."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# -- outline ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OutlineEntry(BaseModel):
|
|
73
|
+
"""One paragraph node in a paginated outline page."""
|
|
74
|
+
|
|
75
|
+
node_id: str = Field(..., description="Durable node id; use it as an edit target.")
|
|
76
|
+
kind: str = Field(..., description="Node kind, e.g. 'paragraph'.")
|
|
77
|
+
preview: str = Field(..., description="Truncated text preview for discovery.")
|
|
78
|
+
style: Optional[str] = Field(default=None, description="Paragraph style name, if any.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OutlineResponse(BaseModel):
|
|
82
|
+
"""A bounded outline page plus the cursor to fetch the next one."""
|
|
83
|
+
|
|
84
|
+
nodes: list[OutlineEntry]
|
|
85
|
+
next_cursor: Optional[str] = Field(
|
|
86
|
+
default=None, description="Pass back as ?cursor= to page forward; null when done."
|
|
87
|
+
)
|
|
88
|
+
total: int = Field(..., description="Total paragraph count in the document.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# -- edit ---------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EditRequest(BaseModel):
|
|
95
|
+
"""Body for POST /sessions/{id}/edit — apply ONE small intent-named edit.
|
|
96
|
+
|
|
97
|
+
``op`` selects the intent and its required payload:
|
|
98
|
+
|
|
99
|
+
* ``replace_text`` — node_id, before (exact current text), after
|
|
100
|
+
* ``insert_text_after`` — node_id, anchor (exact text to insert after), text
|
|
101
|
+
* ``delete_text`` — node_id, before (exact text to delete)
|
|
102
|
+
* ``set_paragraph_style`` — node_id, style (new), before (current style name)
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
op: EditOp = Field(..., description="The edit intent.")
|
|
106
|
+
node_id: str = Field(..., description="Target node id (from the outline).")
|
|
107
|
+
before: Optional[str] = Field(
|
|
108
|
+
default=None,
|
|
109
|
+
description="Exact CURRENT text/style to match; the server refuses blind overwrites.",
|
|
110
|
+
)
|
|
111
|
+
after: Optional[str] = Field(default=None, description="Replacement text for replace_text.")
|
|
112
|
+
anchor: Optional[str] = Field(
|
|
113
|
+
default=None, description="Exact text to insert after (insert_text_after)."
|
|
114
|
+
)
|
|
115
|
+
text: Optional[str] = Field(default=None, description="Text to insert (insert_text_after).")
|
|
116
|
+
style: Optional[str] = Field(
|
|
117
|
+
default=None, description="New paragraph style (set_paragraph_style)."
|
|
118
|
+
)
|
|
119
|
+
rationale: Optional[str] = Field(default=None, description="Optional declared 'why'.")
|
|
120
|
+
prompt: Optional[str] = Field(
|
|
121
|
+
default=None, description="Optional prompt; hashed to prompt_sha256, never stored verbatim."
|
|
122
|
+
)
|
|
123
|
+
turn_id: Optional[str] = Field(default=None, description="Optional declared turn id.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EditResponse(BaseModel):
|
|
127
|
+
"""Result of one journaled edit."""
|
|
128
|
+
|
|
129
|
+
op_id: str = Field(..., description="Stable id of the journaled op.")
|
|
130
|
+
seq: int = Field(..., description="Server-assigned monotonic sequence number.")
|
|
131
|
+
node_id: str = Field(..., description="Resolved target node id.")
|
|
132
|
+
provenance_source: str = Field(..., description="'declared' or 'observed'.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# -- save / changes -----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SaveRequest(BaseModel):
|
|
139
|
+
"""Body for POST /sessions/{id}/save — render the tracked .docx."""
|
|
140
|
+
|
|
141
|
+
out: str = Field(
|
|
142
|
+
...,
|
|
143
|
+
description="Absolute server-side .docx path to write the native-revisions file to.",
|
|
144
|
+
examples=["/data/contract.tracked.docx"],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SaveResponse(BaseModel):
|
|
149
|
+
"""Paths + verification result after a save."""
|
|
150
|
+
|
|
151
|
+
tracked_path: str
|
|
152
|
+
changex_path: str
|
|
153
|
+
ops: int = Field(..., description="Number of active (non-reverted) ops written.")
|
|
154
|
+
verified: bool = Field(..., description="Whether the journal hash-chain verified.")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ChangesResponse(BaseModel):
|
|
158
|
+
"""The structured provenance journal (active, non-reverted events)."""
|
|
159
|
+
|
|
160
|
+
session_id: str
|
|
161
|
+
events: list[dict[str, Any]]
|
|
162
|
+
count: int
|
|
163
|
+
verified: bool
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# -- passive open / seal ------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PassiveOpenRequest(BaseModel):
|
|
170
|
+
"""Body for POST /open — start a passive (no-tool-calling) capture session."""
|
|
171
|
+
|
|
172
|
+
docx: str = Field(..., description="Absolute path to the .docx to snapshot.")
|
|
173
|
+
changex: Optional[str] = Field(
|
|
174
|
+
default=None, description="Optional sidecar journal path (.changex/.jsonl)."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PassiveOpenResponse(BaseModel):
|
|
179
|
+
"""Outcome of POST /open."""
|
|
180
|
+
|
|
181
|
+
changex_path: str
|
|
182
|
+
session_id: str
|
|
183
|
+
baseline_sha256: str
|
|
184
|
+
paragraphs: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PassiveSealRequest(BaseModel):
|
|
188
|
+
"""Body for POST /seal — diff the edited docx vs the stored baseline."""
|
|
189
|
+
|
|
190
|
+
docx: str = Field(..., description="Absolute path to the (now edited) .docx.")
|
|
191
|
+
changex: Optional[str] = Field(default=None, description="Optional sidecar journal path.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PassiveSealResponse(BaseModel):
|
|
195
|
+
"""Honest, degraded capture counts from a passive seal."""
|
|
196
|
+
|
|
197
|
+
changex_path: str
|
|
198
|
+
appended: int
|
|
199
|
+
replaced: int
|
|
200
|
+
inserted: int
|
|
201
|
+
deleted: int
|
|
202
|
+
style_changed: int
|
|
203
|
+
baseline_unchanged: bool
|
|
204
|
+
degraded: bool = Field(
|
|
205
|
+
default=True,
|
|
206
|
+
description="Always true: passive ops are observed net deltas, not true provenance.",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# -- report -------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ReportResponse(BaseModel):
|
|
214
|
+
"""A rendered HTML/markdown redline of a session's journal."""
|
|
215
|
+
|
|
216
|
+
format: str = Field(..., description="'html' or 'markdown'.")
|
|
217
|
+
report: str = Field(..., description="The rendered redline.")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# -- shared -------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class HealthResponse(BaseModel):
|
|
224
|
+
"""Liveness probe payload."""
|
|
225
|
+
|
|
226
|
+
status: Literal["ok"] = "ok"
|
|
227
|
+
service: str = "changex-api"
|
|
228
|
+
version: str
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ErrorResponse(BaseModel):
|
|
232
|
+
"""Structured boundary error mirroring the MCP ToolError shape."""
|
|
233
|
+
|
|
234
|
+
error: str = Field(..., description="Stable machine-readable code, e.g. 'split_required'.")
|
|
235
|
+
detail: str = Field(..., description="Human/agent-facing instruction.")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: changex-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ChangeX HTTP/REST API: a thin FastAPI wrapper over the changex-core spine so any app or model — local/offline LLMs, or a ChatGPT custom GPT Action consuming /openapi.json — can open, edit, save, and review tracked Word documents over HTTP.
|
|
5
|
+
Author: ChangeX
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: actions,ai,docx,fastapi,llm,openapi,provenance,rest,track-changes
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
13
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: changex-core>=0.1.0
|
|
16
|
+
Requires-Dist: fastapi>=0.110
|
|
17
|
+
Requires-Dist: uvicorn>=0.27
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# changex-api
|
|
26
|
+
|
|
27
|
+
A thin HTTP/REST API over the [ChangeX](https://github.com/ArioMoniri/changex)
|
|
28
|
+
core spine, so **any** app or model can drive provenance-first tracked editing of
|
|
29
|
+
Word documents over plain HTTP — a local/offline LLM with no function-calling, a
|
|
30
|
+
`curl` script, or a **ChatGPT custom GPT Action** that imports the
|
|
31
|
+
auto-generated OpenAPI schema.
|
|
32
|
+
|
|
33
|
+
It wraps `changex-core` and reuses the exact MCP tool semantics
|
|
34
|
+
(`changex-mcp`): an `op` discriminator selects one small intent, the exact
|
|
35
|
+
`before` substring is always carried so blind overwrites are refused, and an
|
|
36
|
+
oversized op is rejected so the model splits the change.
|
|
37
|
+
|
|
38
|
+
## Run
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# install (workspace) and launch on 127.0.0.1:8000
|
|
42
|
+
uv sync
|
|
43
|
+
changex-api # or: python -m changex_api
|
|
44
|
+
# custom bind / port:
|
|
45
|
+
changex-api --host 0.0.0.0 --port 9000 # non-local host REQUIRES a token:
|
|
46
|
+
CHANGEX_API_TOKEN=secret changex-api --host 0.0.0.0
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Bind is `127.0.0.1` by default. A non-local host is **refused** unless
|
|
50
|
+
`CHANGEX_API_TOKEN` is set; when it is, every non-`/healthz` route requires
|
|
51
|
+
`Authorization: Bearer <token>`.
|
|
52
|
+
|
|
53
|
+
## Endpoints
|
|
54
|
+
|
|
55
|
+
| Method & path | Purpose |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `POST /sessions` | Open a `.docx` for tracked editing (returns a `handle`). |
|
|
58
|
+
| `GET /sessions/{handle}/outline` | Bounded, paginated paragraph outline (discover `node_id`s). |
|
|
59
|
+
| `POST /sessions/{handle}/edit` | One small, intent-named tracked edit. |
|
|
60
|
+
| `POST /sessions/{handle}/save` | Write the native-revisions `.docx` + `.changex` journal. |
|
|
61
|
+
| `GET /sessions/{handle}/changes` | The structured provenance journal. |
|
|
62
|
+
| `POST /open` | Passive (no-tool-calling) capture: snapshot a docx. |
|
|
63
|
+
| `POST /seal` | Diff the edited docx vs the baseline; append passive ops. |
|
|
64
|
+
| `POST /report` | Render an HTML/markdown redline (by `handle` or `.changex` path). |
|
|
65
|
+
| `GET /healthz` | Liveness probe (never requires auth). |
|
|
66
|
+
|
|
67
|
+
## OpenAPI / function-calling schemas
|
|
68
|
+
|
|
69
|
+
FastAPI serves the schema at **`/openapi.json`** — that file IS the ChatGPT
|
|
70
|
+
custom GPT Action schema; point a GPT's Action import at it. Static copies plus
|
|
71
|
+
OpenAI/Gemini function-calling schemas live in the repo's `integrations/`:
|
|
72
|
+
|
|
73
|
+
- `integrations/openapi.json` — the dumped OpenAPI 3.1 schema (ChatGPT Actions).
|
|
74
|
+
- `integrations/openai-functions.json` — OpenAI `tools` format.
|
|
75
|
+
- `integrations/gemini-functions.json` — Gemini `functionDeclarations` format.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
changex_api/__init__.py,sha256=2nNxP7Q1mZISpiNnCRvw_Wz9riodAwx9V8xcdfvfLvk,1026
|
|
2
|
+
changex_api/__main__.py,sha256=Vnm08OzIjxoAsQjzGcKJ4K8CurqIFr-2X1ktovNd3A8,2489
|
|
3
|
+
changex_api/app.py,sha256=ho0MUDaZa6p0u99c00Em905r3gefgr1G7ALWvmP9Dzk,16117
|
|
4
|
+
changex_api/models.py,sha256=HwpUEIIiOm-iKx0q7BtNg1B0xTig2sarDnI7-WmxoIU,8474
|
|
5
|
+
changex_api-0.1.0.dist-info/METADATA,sha256=YRS9U7UlqUVtAsNPidSPXRJ0KSPqSioKuOyLR0VW9XM,3425
|
|
6
|
+
changex_api-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
changex_api-0.1.0.dist-info/entry_points.txt,sha256=LdyIM_hFucRbamN3wGvF_U7WBzUD3mMAbfrJm22dCa8,58
|
|
8
|
+
changex_api-0.1.0.dist-info/licenses/LICENSE,sha256=5qBe8VP3umqM2WMYBYJ4B9tznqeKkjmIhXZ62pJ0038,1068
|
|
9
|
+
changex_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ario Moniri
|
|
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.
|