Skip to content

Commit

Permalink
add python support (#419)
Browse files Browse the repository at this point in the history
* add python support

* Fix route reporting

Also includes some minor cleanup changes

* Add ruff formatter/linter

* Fix linting issues

* Add mypy

* Add pyhumps and types-request as temp example deps

* Export measure

And fix measure as well as add support for traceparent

* Don't record requests with `x-fpx-route-inspector`

Request with this header key set to enabled will not be recorded

* Use fpx_endpoint in setup_span_instrumentation

also: minor cleanup

* Cleanup & organize imports

* Move JSONSpanExporter to seperate file

* Update temporary dependencies for example

* Use dataclasses-json

* Tweak tailwind config to fix build

* Rename `status_code` to `code` for the ts api

* Add logging to JSONSpanExporter

* Add some docstrings

* Add a test

* Update README

* Handle check_error

And please mypy

* Fix running studio for python projects

* Bump package to 0.2.1

As the published version (0.2.0) wasn't up-to-date

Also:

* fix linting issues
* remove superfluous dependencies for python-fastapi example

* Fix formatting complaints

* Remove unused Tiltfile

* Bump version for types package

* bump @fiberplane/source-analysis version

* bump version for @fiberplane/studio

* Bump version

* Remove unsupported request headers

* Bump version of studio

* Rename fpx.py to fpxpy

* Improve typescript not found messaging

* Update readme

* Tweak readme

(and fix some formatting issues

* Add a logger

* Change the FastAPI supported range

(and change the link to the fastapi package in the readme)

* update model protocol sdk

* .log to .error + add body handling

Changed console.log to console.error so they don't clash with stdio
transport messages. Also make sure the send request tool can handle body
payloads

* Add logging support

* Update readme

* Fix formatting

* Tweak python-fastapi example docs

* update mcp version

* Add py.typed file

* Add mypy is a devdep to python-fastapi

* Updaet @measure info slightly

* Bump version of fpxpy

* Update version for @fiberplane/source-analysis

* Please the biome formatting overlord

---------

Co-authored-by: Jacco Flenter <[email protected]>
Co-authored-by: Laurynas Keturakis <[email protected]>
  • Loading branch information
3 people authored Jan 15, 2025
1 parent 76d0039 commit bc68526
Show file tree
Hide file tree
Showing 38 changed files with 3,812 additions and 124 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Fiberplane Studio is a local tool for building and debugging Hono APIs. It can m

## Quick Start

For FastAPI/Python developers, we've got an experimental library in the [fpxpy](./fpxpy) folder.

Create Hono project

```sh
Expand Down Expand Up @@ -51,6 +53,7 @@ Visit `http://localhost:8788` to make requests to your api, and see your logs an

Studio is designed to be used in conjunction with the [`@fiberplane/hono-otel` client library](https://www.npmjs.com/package/@fiberplane/hono-otel). You can read more about what it does in [that project's README](./packages/client-library-otel/README.md).


## Contributing

See [`DEVELOPMENT.md`](./DEVELOPMENT.md) for more details on how to run the Studio locally. Please get in touch via GitHub issues, or on the [Fiberplane Discord](https://discord.com/invite/cqdY6SpfVR), if you have any feedback or suggestions!
Expand Down
60 changes: 0 additions & 60 deletions Tiltfile

This file was deleted.

7 changes: 6 additions & 1 deletion api/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const CONFIG_FILE_NAME = "fpx.v0.config.json";
// Paths to relevant project directories and files
const WRANGLER_TOML_PATH = findInParentDirs("wrangler.toml");
const PACKAGE_JSON_PATH = findInParentDirs("package.json");
const PYPROJECT_TOML_PATH = findInParentDirs("pyproject.toml");
// NOTE - Deno projects might also not necessarily have a deno.json
const DENO_CONFIG_PATH = findInParentDirs(["deno.json", "deno.jsonc"]);
const PROJECT_ROOT_DIR = findProjectRoot() ?? process.cwd();
Expand Down Expand Up @@ -84,7 +85,10 @@ async function runWizard() {
}

const MIGHT_BE_CREATING =
IS_INITIALIZING_FPX && !WRANGLER_TOML && !PACKAGE_JSON;
IS_INITIALIZING_FPX &&
!WRANGLER_TOML &&
!PACKAGE_JSON &&
!PYPROJECT_TOML_PATH;

logger.debug("MIGHT_BE_CREATING", MIGHT_BE_CREATING);

Expand Down Expand Up @@ -389,6 +393,7 @@ function findProjectRoot() {
WRANGLER_TOML_PATH,
PACKAGE_JSON_PATH,
DENO_CONFIG_PATH,
PYPROJECT_TOML_PATH,
]);
if (!projectRoot) {
logger.debug("No project root detected");
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.13.0-canary.1",
"version": "0.13.0",
"name": "@fiberplane/studio",
"description": "Local development debugging interface for Hono apps",
"author": "Fiberplane<[email protected]>",
Expand Down
11 changes: 8 additions & 3 deletions api/src/lib/code-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function disableCodeAnalysis() {
}

// Getter function for RoutesResult, this is overwritten in setupCodeAnalysis
let _resultGetter = async (): Promise<RoutesResult> => {
let _resultGetter = async (): Promise<RoutesResult | undefined> => {
return Promise.reject(new Error("Routes not yet parsed"));
};

Expand All @@ -36,7 +36,7 @@ let _resultGetter = async (): Promise<RoutesResult> => {
* When we move into scenarios that require parallel analysis for routes, we will need to call `.clone` on the RouteResult
* to avoid race conditions on the returned object.
*/
export const getResult = async (): Promise<RoutesResult> => {
export const getResult = async (): Promise<RoutesResult | undefined> => {
return _resultGetter();
};

Expand Down Expand Up @@ -71,7 +71,12 @@ export function setupCodeAnalysis(monitor: RoutesMonitor) {
return monitor.lastSuccessfulResult;
}

throw new Error("Failed to get routes");
if (monitor.getFilesCount() > 0) {
logger.warn("Failed to get routes, but there are files to analyze");
throw new Error("Failed to get routes");
}

return undefined;
};

// Add a listener to start the analysis
Expand Down
19 changes: 16 additions & 3 deletions api/src/lib/proxy-request/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,29 @@ export async function executeProxyRequest(
}
}

export function removeUnsupportedHeaders(headers: Record<string, string>) {
const unsupportedHeaders = ["transfer-encoding"];
const validHeaders: Record<string, string> = {};
for (const headerName in headers) {
if (unsupportedHeaders.includes(headerName.toLowerCase())) {
logger.warn("Detected an unsupported header:", { headerName });
} else {
validHeaders[headerName] = headers[headerName];
}
}

return validHeaders;
}

function createProxyRequestFromNewAppRequest(
requestDescription: NewAppRequest,
) {
const { requestUrl, requestMethod, requestBody } = requestDescription;

let { requestHeaders } = requestDescription;

if (!requestHeaders) {
requestHeaders = {};
}
// Cleanup requestHeaders to remove unsupported headers
requestHeaders = removeUnsupportedHeaders(requestHeaders || {});

let validBody: Required<RequestInit>["body"] | null = null;
if (requestBody != null) {
Expand Down
60 changes: 33 additions & 27 deletions api/src/routes/app-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
executeProxyRequest,
handleFailedRequest,
handleSuccessfulRequest,
removeUnsupportedHeaders,
} from "../lib/proxy-request/index.js";
import type { Bindings, Variables } from "../lib/types.js";
import {
Expand Down Expand Up @@ -77,32 +78,37 @@ app.get("/v0/app-routes-file-tree", async (ctx) => {
const result = await getResult();

const routeEntries = [];
for (const currentRoute of routes) {
const url = new URL("http://localhost");
url.pathname = currentRoute.path ?? "";
const request = new Request(url, {
method: currentRoute.method ?? "",
});
result.resetHistory();
const response = await result.currentApp.fetch(request);
const responseText = await response.text();

if (responseText !== "Ok") {
logger.warn(
"Failed to fetch route for context expansion",
responseText,
);
continue;
}

const history = result.getHistory();
const routeEntryId = history[history.length - 1];
const routeEntry = result.getRouteEntryById(routeEntryId as RouteEntryId);
if (result) {
for (const currentRoute of routes) {
const url = new URL("http://localhost");
url.pathname = currentRoute.path ?? "";
const request = new Request(url, {
method: currentRoute.method ?? "",
});
result.resetHistory();
const response = await result.currentApp.fetch(request);
const responseText = await response.text();

if (responseText !== "Ok") {
logger.warn(
"Failed to fetch route for context expansion",
responseText,
);
continue;
}

routeEntries.push({
...currentRoute,
fileName: routeEntry?.fileName,
});
const history = result.getHistory();
const routeEntryId = history[history.length - 1];
const routeEntry = result.getRouteEntryById(
routeEntryId as RouteEntryId,
);

routeEntries.push({
...currentRoute,
fileName: routeEntry?.fileName,
});
}
}

const tree = buildRouteTree(
Expand Down Expand Up @@ -312,8 +318,9 @@ app.all(
const requestUrlHeader = proxyToHeader;

// NOTE - These are the headers that will be used in the request to the service
const requestHeaders: Record<string, string> =
constructProxiedRequestHeaders(ctx, headersJsonHeader ?? "", traceId);
const requestHeaders: Record<string, string> = removeUnsupportedHeaders(
constructProxiedRequestHeaders(ctx, headersJsonHeader ?? "", traceId),
);

// Construct the url we want to proxy to, using the query params from the original request
const requestQueryParams = {
Expand All @@ -326,7 +333,6 @@ app.all(
logger.debug("Proxying request to:", requestUrl);
logger.debug("Proxying request with headers:", requestHeaders);

// Create a new request object
// Clone the incoming request, so we can make a proxy Request object
const clonedReq = ctx.req.raw.clone();
const proxiedReq = new Request(requestUrl, {
Expand Down
4 changes: 4 additions & 0 deletions api/src/routes/inference/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ app.post(
provider !== "ollama"
? await getResult()
.then(async (routesResult) => {
if (!routesResult) {
return [null, null];
}

const url = new URL("http://localhost");
url.pathname = path;
const request = new Request(url, { method });
Expand Down
5 changes: 3 additions & 2 deletions api/src/routes/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ app.get("/v1/traces", async (ctx) => {
});

const traceMap = new Map<string, Array<(typeof spans)[0]>>();

for (const span of spans) {
const traceId = span.inner.trace_id;
if (!traceId) {
Expand Down Expand Up @@ -137,7 +136,9 @@ app.post("/v1/traces", async (ctx) => {
);

try {
await db.insert(otelSpans).values(tracesPayload);
if (tracesPayload.length > 0) {
await db.insert(otelSpans).values(tracesPayload);
}
} catch (error) {
logger.error("Error inserting trace", error);
return ctx.text("Error inserting trace", 500);
Expand Down
3 changes: 3 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@
],
"files": {
"ignore": [
// Python related
".venv",
".mypy_cache",
// API related
"meta/*.json",
// Honc code gen playground outputs
Expand Down
1 change: 1 addition & 0 deletions examples/python-fastapi/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
1 change: 1 addition & 0 deletions examples/python-fastapi/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
14 changes: 14 additions & 0 deletions examples/python-fastapi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# python-fastapi example

This project uses `uv`. To run:

```
$ uv sync --frozen
$ FPX_ENDPOINT=http://localhost:8788/v1/traces uv run fastapi dev main.py
```

Of course you also need to run studio next to it, for that you need [`npx`](https://docs.npmjs.com/cli/v7/commands/npx) . So you can run:

```
npx @fiberplane/studio
```
55 changes: 55 additions & 0 deletions examples/python-fastapi/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from typing import Union
import logging

from time import sleep

from fastapi import FastAPI
from fpxpy import measure, setup


app = FastAPI()
setup(app)

# Example logger
logger = logging.getLogger(__name__)
# Set the log level to log everything
logger.setLevel(logging.DEBUG)


@app.get("/")
def read_root():
"""
Example index that returns a JSON object
"""
loop()
return {"Hello": "World"}


@measure(name="loop")
def loop(n: int = 10) -> None:
for i in range(n):
sleep(0.1)
# Log the loop number
# This will be captured by FPX
# Unfortunately this will not appear in the terminal console
# When using `FPX_ENDPOINT=http://localhost:8788/v1/traces uv run fastapi dev ./main.py`
logger.info("loop %i", i)


@app.get("/hello")
async def root():
return {"message": "Hello World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
"""
Endpoint that returns JSON object with some of the parameters
Args:
item_id (int): _description_
q (Union[str, None], optional): _description_. Defaults to None.
Returns:
_type_: _description_
"""
return {"item_id": item_id, "q": q}
15 changes: 15 additions & 0 deletions examples/python-fastapi/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "python-fastapi"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard]>=0.115.6",
"fpxpy>=0.2.0",
]

[dependency-groups]
dev = [
"mypy>=1.14.1",
]
Loading

0 comments on commit bc68526

Please sign in to comment.