SIGN IN SIGN UP

feat(api): support `.[extras]` requirements (#1016)

* fix(api): default kind="virtualenv" for python_entry_point apps

The entry-point loader bypasses ``Host.parse_options`` (which is where
``Options.environment["kind"]`` would normally be defaulted for
``@fal.function``/``wrap_app``), so dispatching such an app failed at
``define_environment`` with a missing-arg error. Default the kind in
``_load_from_python_entry_point`` to match the decorator path.

* feat(api): materialize local-path requirements into uploaded sdists

When a ``[tool.fal.apps.*]`` block lists ``.`` or ``.[extras]`` in its
``requirements`` (the natural way to say "install this project itself
on the worker"), build an sdist of the project locally, upload it via
``fal.toolkit.File.from_path``, and rewrite the requirement to
``<package>[extras] @ <url>`` so pip can install it remotely.

Implementation:

* ``fal.api._sdist`` is a pure helper that detects local-path entries,
  shells out to ``python -m build --sdist``, uploads the artefact, and
  rewrites the requirements list (preserving flat vs layered shape).
  An in-process cache keyed by resolved project root short-circuits
  repeated dispatches in the same invocation (e.g. ``fetch_metadata``
  followed by ``_run``) so we don't rebuild and re-upload every time.

* ``FalServerlessHost`` gains a ``local_project_root`` field and a
  ``_materialize_local_requirements`` hook called from both
  ``register`` (deploy) and ``_run`` right after copying
  ``options.environment``. ``LocalHost`` is unaffected since it
  rejects entrypoint mode anyway.

* The CLI threads the project root from ``find_pyproject_toml`` into
  ``AppData`` and on through ``_create_host``.

UX matches the existing ``Building environment...`` phase: a bold
``Packaging local project <name>...`` header, a dim rule, the live
``python -m build`` output, the upload progress line, a closing rule,
and a green ``✓ Project packaged`` footer. The cached path is silent
to avoid noise on every dispatch.

Tested live against the ``python_entry_point/simple`` example app —
build/upload/rewrite/install round-trips end-to-end and the function
runs on the worker. 30 unit tests cover the rewrite logic, regex
shape, layered-list preservation, project-root caching, and progress
event contract.

* chore(api): harden _sdist temp dir cleanup and cache concurrency

Three small follow-ups from the review on #1016:

* ``_build_sdist`` wraps its body in a single ``try/except BaseException``
  that deletes the ``mkdtemp`` outdir on any failure before re-raising,
  so ``KeyboardInterrupt`` and other unexpected exceptions between
  ``mkdtemp`` and the per-branch cleanup can no longer leak the dir.

* ``_SDIST_URL_CACHE`` is guarded by a module-level
  ``threading.Lock``. Two concurrent dispatches with the same project
  root used to both miss, both build, and both upload; the second now
  waits for the first to populate the cache and re-uses it.

* ``_SDIST_URL_CACHE`` type annotation tightened from bare ``dict`` to
  ``dict[str, tuple[str, str]]`` to document the cached shape.

* chore(api): tidy _sdist docstring and cached upload_finished payload

Two follow-ups from the latest review on #1016:

* ``materialize_local_paths`` docstring used to claim "once per content
  hash" — a leftover from an earlier draft. The cache is keyed on
  resolved project root path; correct the wording.

* The cached path emitted ``sdist_size=0`` on ``upload_finished``,
  which is meaningless for a cache hit and a footgun for callers
  forwarding the value to analytics. Emit ``sdist_size=None`` and
  document the convention in the event-contract comment.

* chore(fal): declare `build` as a runtime dep

``fal.api._sdist`` shells out to ``python -m build`` whenever a
``[tool.fal.apps.*]`` requirement uses ``.``/``.[extras]``. Until now
we relied on ``build`` happening to be present in the user's env;
make the dependency explicit so the feature works on a fresh install.

Also drop the "If ``python -m build`` is not installed, run
``pip install build``" hint from the build-failure error — it's no
longer reachable with the package as a hard dep.

* chore(api): clarify _sdist lock scope and build_finished no-op

Two comment-only nits from the latest review on #1016:

* ``_SDIST_URL_CACHE_LOCK`` is a single global lock — the prior comment
  claimed it was scoped "to the same project root", which was
  misleading. Rewrite the comment to be honest about the trade-off (a
  finer per-root lock would let independent projects build in parallel,
  but the realistic shape today is at most one project per ``fal run``).

* ``_on_progress`` in ``_materialize_local_requirements`` doesn't render
  ``build_finished``. Note in-line that this is intentional — the live
  ``python -m build`` output between the rules already tells the user
  the build is done — so the omission doesn't read as an oversight.

* chore(api): require exactly one sdist artefact and cover failure paths

Follow-ups from the latest review on #1016:

* ``_build_sdist`` used to pick ``sorted(outdir.glob(...))[-1]`` as a
  defensive fallback when the outdir somehow held more than one
  tarball. Since the outdir is a fresh ``mkdtemp`` and
  ``python -m build --sdist`` emits exactly one artefact, silently
  picking the alphabetic last is the wrong behaviour — anything other
  than one file means something has gone sideways. Require exactly
  one and raise a descriptive error otherwise.

* Add two ``_build_sdist`` failure tests that mock ``subprocess.run``:
  one for a non-zero exit and one for a zero exit that left no
  artefact. Both assert the ``RuntimeError`` shape and that the temp
  outdir gets cleaned up. Closes a coverage gap — previously every
  test mocked ``_build_sdist`` whole.

* Merge an accidental split f-string in ``_materialize_local_requirements``
  (artefact of an earlier ruff-format pass).
R
Ruslan Kuprieiev committed
a76250be5255e896e2ece34802489b474692d489
Parent: 8137b12
Committed by GitHub <noreply@github.com> on 5/11/2026, 1:24:35 PM