Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,9 @@ func (s *Builder) Upgrade(ctx context.Context, req *builderv0.UpgradeRequest) (*
// Parameters is the template parameter set for the k8s deployment.
type Parameters struct {
Probes string
// GRPCEnabled adds the gRPC containerPort and Service port to the rendered
// manifests. GRPCPort is the fixed in-cluster port the grpc.aio listener
// binds (the app defaults to it when CODEFLY_GRPC_PORT is unset).
// GRPCEnabled adds the gRPC containerPort, Service port and
// CODEFLY_GRPC_PORT, the variable the app binds its grpc.aio listener on.
// GRPCPort is the fixed in-cluster port it binds.
GRPCEnabled bool
GRPCPort int
// ConnectEnabled adds the Connect face's containerPort, Service port and
Expand Down
10 changes: 8 additions & 2 deletions grpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,10 @@ func TestGRPCEndpointReloadsContract(t *testing.T) {
require.Contains(t, names, "Echo")
}

// TestDeploymentRendersGRPCPort proves the gRPC container/service ports appear
// only when the deployment parameters opt in.
// TestDeploymentRendersGRPCPort proves the gRPC container/service ports, and
// the CODEFLY_GRPC_PORT the app binds on, appear only when the deployment
// parameters opt in: a deployed service never guesses the port the Service
// routes to.
func TestDeploymentRendersGRPCPort(t *testing.T) {
enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{GRPCEnabled: true, GRPCPort: 9090}))
svc, err := os.ReadFile(filepath.Join(enabled, "base", "service.yaml"))
Expand All @@ -202,9 +204,13 @@ func TestDeploymentRendersGRPCPort(t *testing.T) {
deployment, err := os.ReadFile(filepath.Join(enabled, "base", "deployment.yaml"))
require.NoError(t, err)
require.Contains(t, string(deployment), "containerPort: 9090")
require.Contains(t, string(deployment), "- name: CODEFLY_GRPC_PORT\n value: \"9090\"")

disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{}))
svc, err = os.ReadFile(filepath.Join(disabled, "base", "service.yaml"))
require.NoError(t, err)
require.NotContains(t, string(svc), "grpc-port")
deployment, err = os.ReadFile(filepath.Join(disabled, "base", "deployment.yaml"))
require.NoError(t, err)
require.NotContains(t, string(deployment), "CODEFLY_GRPC_PORT")
}
6 changes: 6 additions & 0 deletions templates/deployment/kustomize/base/deployment.yaml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ spec:
# writable scratch mount so libraries that use ~/.cache still work.
- name: HOME
value: /tmp
{{- if .Deployment.Parameters.GRPCEnabled }}
# grpc-server is on: the app binds its grpc.aio listener on this
# port, the one the grpc containerPort and the Service declare.
- name: CODEFLY_GRPC_PORT
value: "{{ .Deployment.Parameters.GRPCPort }}"
{{- end }}
{{- if .Deployment.Parameters.ConnectEnabled }}
# connect-endpoint is on: the app serves the Connect face on this
# port, and only when the variable is set.
Expand Down
4 changes: 2 additions & 2 deletions templates/factory/code/src/main.py.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ for plugin in plugins:
{{- if .GRPCEnabled }}

# gRPC server — runs a grpc.aio listener in this process alongside FastAPI.
# The port is provided by codefly at runtime; it falls back to the standard
# gRPC port in container/Kubernetes where the mapping is fixed.
# codefly hands the process its port as CODEFLY_GRPC_PORT, locally and in the
# deployment.
_grpc_server = None
# The Connect face (Connect, gRPC-Web and gRPC on one listener over the gRPC
# server): codefly hands the process CODEFLY_CONNECT_PORT exactly when the
Expand Down
13 changes: 13 additions & 0 deletions testdata/recipe315327749/mod/svc-1793294758768/GETTING_STARTED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Welcome to your `fastapi` project!

This agent follows the [fastapi best practices](https://github.com/zhanymkanov/fastapi-best-practices) layout.

Dependency management uses [uv](https://docs.astral.sh/uv/). Common commands:

- `uv sync` — install/refresh the venv from `pyproject.toml` + `uv.lock`
- `uv add <package>` — add a runtime dependency
- `uv add --dev <package>` — add a dev dependency
- `uv run pytest` — run tests
- `uv run uvicorn src.main:app --reload` — run the dev server

`codefly run service <name>` does all of the above for you via the agent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Codefly's uvicorn logging filter. Generated by the python-fastapi agent;
regenerated on every start, so edits here do not survive. Health probes are
not traffic: an access-log line for /healthz or /readyz is dropped before it is
formatted, and every other line is written exactly as uvicorn writes it."""
import logging
import re

PROBE_PATHS = re.compile(r"^/(healthz|readyz|livez|health|ready|live|ping)$")


class ProbeFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
args = record.args
if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str):
path = args[2].split("?", 1)[0]
if PROBE_PATHS.match(path):
return False
return True
41 changes: 41 additions & 0 deletions testdata/recipe315327749/mod/svc-1793294758768/code/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[project]
name = "base"
version = "0.0.0"
description = ""
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.109.0",
"uvicorn>=0.25.0",
"codefly-sdk>=0.0.14",
"pydantic>=2.9.2",
]

[dependency-groups]
dev = [
"pytest>=8.2.0",
"httpx>=0.27.0",
"pytest-asyncio>=0.23.6",
"codefly-cli>=0.0.19",
"ruff>=0.6.0",
]

[tool.uv]
package = false
# A CONSTRAINT, not a dependency: it bounds a version only if something
# else pulls the package in. multidict 7.1.0 (PyPI, 2026-10-09) shipped
# without manylinux wheels, so `uv sync` in the Debian runtime had nothing
# to install and no compiler to build it -- every fresh service failed at
# Create. Lift it once a 7.1.x with manylinux wheels exists; the durable
# fix is a uv.lock shipped with this template so a create never resolves
# whatever PyPI published an hour ago.
constraint-dependencies = ["multidict<7.1"]

[tool.pytest.ini_options]
# "." — not "src" — so the `from src.X` imports in the scaffolded tests
# resolve. src/ ships with an __init__.py making it a proper package.
pythonpath = ["."]
testpaths = ["tests"]
# pytest-asyncio 1.x needs an explicit mode. "auto" picks up async def
# tests without needing @pytest.mark.asyncio on every one; keeps the
# boilerplate low for scaffolded services.
asyncio_mode = "auto"
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel


class Version(BaseModel):
version: str
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import APIRouter

from src.admin.models import Version
from src.admin.version import get_version

router = APIRouter()


@router.get("/version", response_model=Version)
async def version():
return get_version()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from codefly_sdk import codefly

from src.admin.models import Version


def get_version() -> Version:
return Version(version=codefly.get_version())
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from collections.abc import Callable
from dataclasses import dataclass, field

from fastapi import APIRouter


@dataclass
class Plugin:
"""Plugin interface for FastAPI service extensions.

Each plugin contributes a router, optional startup/shutdown hooks,
and optional migration paths.
"""

name: str
router: APIRouter
prefix: str = ""
tags: list[str] = field(default_factory=list)
startup: Callable | None = None
shutdown: Callable | None = None
migrations: list[str] = field(default_factory=list)
60 changes: 60 additions & 0 deletions testdata/recipe315327749/mod/svc-1793294758768/code/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import os

from codefly_sdk import codefly
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware


def _service_manifest_reachable() -> bool:
# codefly.init() walks parent directories for service.codefly.yaml but does
# not stop at the filesystem root, so it loops forever when the manifest is
# absent (e.g. tests running against an isolated source snapshot). Only init
# when the manifest is actually reachable; runtime config is otherwise
# supplied through CODEFLY__* environment variables.
directory = os.getcwd()
while True:
if os.path.isfile(os.path.join(directory, "service.codefly.yaml")):
return True
parent = os.path.dirname(directory)
if parent == directory:
return False
directory = parent


if _service_manifest_reachable():
codefly.init()

app = FastAPI()

# CORS will be done properly in next release
origins = [
"*",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Initialize configuration before importing routes or user-provided plugins.
# Routes
from src.admin.router import router as admin

app.include_router(admin)

# Plugins
from src.plugins.registry import plugins

for plugin in plugins:
app.include_router(plugin.router, prefix=plugin.prefix, tags=plugin.tags)
if plugin.startup:
app.add_event_handler("startup", plugin.startup)
if plugin.shutdown:
app.add_event_handler("shutdown", plugin.shutdown)

if __name__ == "__main__":
import uvicorn

uvicorn.run(app)
24 changes: 24 additions & 0 deletions testdata/recipe315327749/mod/svc-1793294758768/code/src/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# This script generates the OpenAPI schema for the FastAPI application and saves it to a file.
# This is used by the agent - please do not modify
import json
import os

from codefly_sdk import codefly
from fastapi.openapi.utils import get_openapi

from src.main import app

if __name__ == "__main__":
current_file_path = os.path.abspath(__file__)
openapi_dir = os.path.abspath(os.path.join(current_file_path, "../../../openapi"))
openapi_file_path = os.path.join(openapi_dir, "api.swagger.json")

openapi_schema = get_openapi(
title=codefly.get_service(),
version=codefly.get_version(),
routes=app.routes,
)
app.openapi_schema = openapi_schema
openapi = app.openapi()
with open(openapi_file_path, "w") as f:
f.write(json.dumps(openapi))
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from src.framework.plugin import Plugin

# Plugin registry — module agents add plugin instances here.
plugins: list[Plugin] = []
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import os

# Provide the runtime configuration the app reads without depending on a
# service.codefly.yaml on disk: the test runner executes against an isolated
# source snapshot where the manifest isn't present, and codefly.init()'s
# parent-directory walk would otherwise spin. setdefault preserves any real
# values injected by the codefly runtime.
os.environ.setdefault("CODEFLY__MODULE", "mod")
os.environ.setdefault("CODEFLY__SERVICE", "svc")
os.environ.setdefault("CODEFLY__SERVICE_VERSION", "0.0.0")

import pytest
from httpx import ASGITransport, AsyncClient

from src.admin.models import Version
from src.admin.version import get_version
from src.main import app


@pytest.mark.asyncio
async def test_version():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://localhost:8080"
) as ac:
response = await ac.get("/version")
assert response.status_code == 200

assert Version.model_validate(response.json()) == get_version()
Loading
Loading