Find a study wherever it is, and start on a port that is free
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m10s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m3s
Test Backend / test-backend (push) Successful in 2m32s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m9s

Three things the one-folder-per-study layout ran into.

**Discovery walks down.** A plain directory is now walked all the way, so
`fluksio sync dev` finds `dev/s1_baseline/study.py` and naming each study is
no longer the price of the layout. Hidden directories, `__pycache__`,
`node_modules` and virtualenvs are left alone, and a package is taken whole.

Two files that would import under one module name are refused, naming both:
Python keeps one module per name, so the second would silently *be* the first
— and a node's generated body imports by that name, so a worker would run the
wrong study's code. The message says the fix, which is an `__init__.py` per
study directory. A module that raises while importing is now a sentence
naming the file rather than an importlib traceback.

**`run` and `sweep` sync downwards too**, so the flow is found from the
repository root without the sync-then-`--no-sync` two-step. A study that will
not import is a warning rather than a stopped run, since a walk meets every
study and a half-finished one two directories away is not this run's problem.
The upload was already a no-op for a flow nothing changed in, so what the walk
costs is import time — `--sync PATH` narrows it, and skipping unchanged
subtrees would need a cache keyed on file state that is deliberately not here.

**`serve` moves off a busy default port** — 8001, 8002, up to twenty — says
which it took, and writes that one into `client.json`. A port given with
`--port` still fails when it is taken, because naming one is asking for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
This commit is contained in:
2026-08-28 08:59:14 +02:00
co-authored by Claude Opus 5
parent 9dc1fe0a84
commit 9387755e59
4 changed files with 259 additions and 36 deletions
+76
View File
@@ -388,3 +388,79 @@ def test_an_engine_without_the_route_is_named_rather_than_404() -> None:
assert "engine is 0.1.4" in _too_old(Old())
assert "engine is older" in _too_old(Ancient())
assert "pip install -U fluksio" in _too_old(Ancient())
def test_a_study_in_a_subfolder_is_found(tmp_path) -> None:
"""One directory per study is a layout; naming each is bookkeeping."""
from fluksio.sdk.cli import _below
(tmp_path / "dev" / "s1").mkdir(parents=True)
(tmp_path / "dev" / "s2" / "inner").mkdir(parents=True)
(tmp_path / "results").mkdir()
(tmp_path / "pkg").mkdir()
(tmp_path / ".hidden").mkdir()
(tmp_path / "dev" / "s1" / "study.py").write_text("")
(tmp_path / "dev" / "s2" / "inner" / "probe.py").write_text("")
(tmp_path / "results" / "notes.py").write_text("")
(tmp_path / "pkg" / "__init__.py").write_text("")
(tmp_path / "pkg" / "deep.py").write_text("")
(tmp_path / ".hidden" / "skip.py").write_text("")
found = {path.relative_to(tmp_path).as_posix() for path in _below(tmp_path)}
# However deep, plus packages whole — and nothing under a dot directory.
assert found == {
"dev/s1/study.py",
"dev/s2/inner/probe.py",
"results/notes.py",
"pkg",
}
def test_two_files_of_one_name_are_refused(tmp_path) -> None:
"""Python keeps one module per name, and a node's body imports by it."""
import pytest
from fluksio.sdk import SyncError
from fluksio.sdk.cli import _import, _module_of
for study in ("s1", "s2"):
(tmp_path / study).mkdir()
(tmp_path / study / "study.py").write_text("VALUE = 1\n")
first = tmp_path / "s1" / "study.py"
second = tmp_path / "s2" / "study.py"
_import(*_module_of(first), first)
with pytest.raises(SyncError, match="both import as 'study'"):
_import(*_module_of(second), second)
def test_run_and_sweep_take_what_to_sync() -> None:
from fluksio.cli import _parser
parser = _parser()
assert parser.parse_args(["sweep", "train", "--sync", "dev/s1"]).sync == ["dev/s1"]
args, _ = parser.parse_known_args(["run", "train", "--sync", "dev/s1"])
assert args.sync == ["dev/s1"]
# Nothing named means this directory, downwards.
assert parser.parse_args(["sweep", "train"]).sync == []
def test_serve_moves_off_a_port_that_is_taken() -> None:
"""A first start should not die on somebody else's dev server."""
import socket
from fluksio.cli import DEFAULT_PORT, _free_port, _parser
with socket.socket() as held:
held.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
held.bind(("127.0.0.1", 0))
held.listen(1)
taken = held.getsockname()[1]
assert _free_port("127.0.0.1", taken) == taken + 1
# A port that was asked for is not moved off: that is what asking means.
assert _parser().parse_args(["serve"]).port is None
assert _parser().parse_args(["serve", "--port", "9000"]).port == 9000
assert DEFAULT_PORT == 8000