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
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:
+43
-3
@@ -19,6 +19,7 @@ import argparse
|
|||||||
import copy
|
import copy
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -230,6 +231,32 @@ CONCURRENCY_FLAGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: What `serve` listens on when nobody says. Taken often enough — another
|
||||||
|
#: engine, another framework's dev server — that dying on it is the first
|
||||||
|
#: thing a zero-config start would hit.
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
|
#: How far up from it to look before giving up and letting the bind fail.
|
||||||
|
PORT_TRIES = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port(host: str, start: int) -> int:
|
||||||
|
"""The first port from ``start`` that nothing is listening on.
|
||||||
|
|
||||||
|
Probed with the same address and options uvicorn will bind with, so this
|
||||||
|
answers the question uvicorn is about to ask rather than a similar one.
|
||||||
|
"""
|
||||||
|
for port in range(start, start + PORT_TRIES):
|
||||||
|
with socket.socket() as probe:
|
||||||
|
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
probe.bind((host, port))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return port
|
||||||
|
return start
|
||||||
|
|
||||||
|
|
||||||
def cmd_serve(args: argparse.Namespace) -> int:
|
def cmd_serve(args: argparse.Namespace) -> int:
|
||||||
data_dir = _data_dir(args.data_dir, args.shared)
|
data_dir = _data_dir(args.data_dir, args.shared)
|
||||||
for flag, name in CONCURRENCY_FLAGS.items():
|
for flag, name in CONCURRENCY_FLAGS.items():
|
||||||
@@ -267,10 +294,16 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
|||||||
from fluksio.flow import modules
|
from fluksio.flow import modules
|
||||||
from fluksio.main import app
|
from fluksio.main import app
|
||||||
|
|
||||||
|
port = args.port
|
||||||
|
if port is None:
|
||||||
|
port = _free_port(args.host, DEFAULT_PORT)
|
||||||
|
if port != DEFAULT_PORT:
|
||||||
|
_say(f"Port {DEFAULT_PORT} is in use; serving on {port} instead.")
|
||||||
|
|
||||||
# The client talks to this engine, and 0.0.0.0 is not an address to talk
|
# The client talks to this engine, and 0.0.0.0 is not an address to talk
|
||||||
# to — it is a statement about which interfaces to listen on.
|
# to — it is a statement about which interfaces to listen on.
|
||||||
reachable = "127.0.0.1" if args.host in ("0.0.0.0", "::", "") else args.host
|
reachable = "127.0.0.1" if args.host in ("0.0.0.0", "::", "") else args.host
|
||||||
url = f"http://{reachable}:{args.port}"
|
url = f"http://{reachable}:{port}"
|
||||||
token_path = _sign_in(admin_id, url, data_dir)
|
token_path = _sign_in(admin_id, url, data_dir)
|
||||||
|
|
||||||
config = cloud_config.load()
|
config = cloud_config.load()
|
||||||
@@ -303,7 +336,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
|||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=port,
|
||||||
log_level=args.log_level,
|
log_level=args.log_level,
|
||||||
log_config=_log_config(args.log_level),
|
log_config=_log_config(args.log_level),
|
||||||
)
|
)
|
||||||
@@ -374,7 +407,14 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
serve = subparsers.add_parser("serve", help="run the engine")
|
serve = subparsers.add_parser("serve", help="run the engine")
|
||||||
with_data_dir(serve)
|
with_data_dir(serve)
|
||||||
serve.add_argument("--host", default="127.0.0.1")
|
serve.add_argument("--host", default="127.0.0.1")
|
||||||
serve.add_argument("--port", type=int, default=8000)
|
# No default: a port nobody asked for may move when it is taken, and one
|
||||||
|
# that was asked for may not.
|
||||||
|
serve.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help=f"default {DEFAULT_PORT}, or the next free port when it is in use",
|
||||||
|
)
|
||||||
serve.add_argument("--log-level", default="info")
|
serve.add_argument("--log-level", default="info")
|
||||||
serve.add_argument("--admin-email", default=None)
|
serve.add_argument("--admin-email", default=None)
|
||||||
serve.add_argument("--admin-password", default=None)
|
serve.add_argument("--admin-password", default=None)
|
||||||
|
|||||||
+114
-29
@@ -115,19 +115,81 @@ def _module_of(path: Path) -> tuple[str, str]:
|
|||||||
return str(directory), ".".join(reversed(parts))
|
return str(directory), ".".join(reversed(parts))
|
||||||
|
|
||||||
|
|
||||||
def _import(root: str, dotted: str) -> None:
|
#: Directories a walk never goes into: none of them is a study, and some of
|
||||||
|
#: them are enormous.
|
||||||
|
_SKIP_DIRS = frozenset({"__pycache__", "node_modules", "site-packages"})
|
||||||
|
|
||||||
|
|
||||||
|
def _import(root: str, dotted: str, expect: Path | None = None) -> None:
|
||||||
if root not in sys.path:
|
if root not in sys.path:
|
||||||
sys.path.insert(0, root)
|
sys.path.insert(0, root)
|
||||||
importlib.import_module(dotted)
|
module = importlib.import_module(dotted)
|
||||||
|
if expect is None:
|
||||||
|
return
|
||||||
|
# Python keeps one module per name, so a second file importing under a
|
||||||
|
# name already taken is silently the first one — and the generated body
|
||||||
|
# imports by that name too, so a worker would run the wrong study's code.
|
||||||
|
actual = getattr(module, "__file__", "") or ""
|
||||||
|
if actual and Path(actual).resolve() != expect.resolve():
|
||||||
|
raise SyncError(
|
||||||
|
f"two files would both import as '{dotted}':\n"
|
||||||
|
f" {actual}\n {expect}\n"
|
||||||
|
"Python keeps one module per name, and a node's generated body "
|
||||||
|
"imports by that name, so the second would run the first's code. "
|
||||||
|
"Put an `__init__.py` in each directory — they become "
|
||||||
|
f"'<dir>.{dotted}' and stop colliding — or rename one of the files."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def discover(targets: list[str]) -> list[Flow]:
|
def _walkable(entry: Path) -> bool:
|
||||||
|
"""Whether a walk should look inside this directory at all."""
|
||||||
|
return (
|
||||||
|
entry.is_dir()
|
||||||
|
and not entry.name.startswith(".")
|
||||||
|
and entry.name not in _SKIP_DIRS
|
||||||
|
and not (entry / "pyvenv.cfg").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _below(root: Path) -> Iterator[Path]:
|
||||||
|
"""Every module and package under a plain directory, however deep.
|
||||||
|
|
||||||
|
One directory per study — `dev/s1_baseline/study.py` — is a layout people
|
||||||
|
have, and naming each of them on the command line is bookkeeping the tool
|
||||||
|
can do. A package is yielded whole and not descended into: its own walk
|
||||||
|
imports its submodules under the right names.
|
||||||
|
"""
|
||||||
|
for entry in sorted(root.iterdir()):
|
||||||
|
if entry.is_file() and entry.suffix == ".py":
|
||||||
|
yield entry
|
||||||
|
elif _walkable(entry):
|
||||||
|
if (entry / "__init__.py").exists():
|
||||||
|
yield entry
|
||||||
|
else:
|
||||||
|
yield from _below(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_package(directory: Path) -> None:
|
||||||
|
"""A package and every module in it, by their dotted names."""
|
||||||
|
root, dotted = _package_of(directory)
|
||||||
|
_import(root, dotted, directory / "__init__.py")
|
||||||
|
for info in pkgutil.walk_packages(sys.modules[dotted].__path__, f"{dotted}."):
|
||||||
|
importlib.import_module(info.name)
|
||||||
|
|
||||||
|
|
||||||
|
def discover(targets: list[str], keep_going: bool = False) -> list[Flow]:
|
||||||
"""Import what was named and hand back the flows it declared.
|
"""Import what was named and hand back the flows it declared.
|
||||||
|
|
||||||
Imported by dotted name with its root on the path, never from a file
|
Imported by dotted name with its root on the path, never from a file
|
||||||
location: the generated node bodies import the same way, and a module
|
location: the generated node bodies import the same way, and a module
|
||||||
loaded under a different name would generate an import that does not
|
loaded under a different name would generate an import that does not
|
||||||
resolve.
|
resolve.
|
||||||
|
|
||||||
|
``keep_going`` warns about a module that will not import instead of
|
||||||
|
stopping, which is what a *run* wants: a study half-way through an edit
|
||||||
|
two directories away is not a reason to refuse to run this one. A
|
||||||
|
collision between two module names is never skipped — it would produce a
|
||||||
|
node body that imports the wrong file.
|
||||||
"""
|
"""
|
||||||
for target in targets:
|
for target in targets:
|
||||||
path = Path(target)
|
path = Path(target)
|
||||||
@@ -136,30 +198,33 @@ def discover(targets: list[str]) -> list[Flow]:
|
|||||||
continue
|
continue
|
||||||
path = path.resolve()
|
path = path.resolve()
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
_import(*_module_of(path))
|
_import(*_module_of(path), path)
|
||||||
continue
|
continue
|
||||||
if (path / "__init__.py").exists():
|
if (path / "__init__.py").exists():
|
||||||
root, dotted = _package_of(path)
|
_import_package(path)
|
||||||
_import(root, dotted)
|
|
||||||
package = sys.modules[dotted]
|
|
||||||
for info in pkgutil.walk_packages(package.__path__, f"{dotted}."):
|
|
||||||
importlib.import_module(info.name)
|
|
||||||
continue
|
continue
|
||||||
# A plain directory — a repository root, usually. Its own modules,
|
# A plain directory — a repository root, or a directory of studies.
|
||||||
# and the packages inside it: `myresearch/` beside a `README` is the
|
# Its own modules, the packages inside it, and the same again all the
|
||||||
# ordinary shape, and naming it explicitly should not be the price of
|
# way down, so one folder per study needs no naming.
|
||||||
# keeping your code in a package.
|
for entry in _below(path):
|
||||||
for module in sorted(path.glob("*.py")):
|
try:
|
||||||
_import(*_module_of(module))
|
if entry.is_dir():
|
||||||
for child in sorted(path.iterdir()):
|
_import_package(entry)
|
||||||
if child.name.startswith(".") or not (child / "__init__.py").exists():
|
else:
|
||||||
continue
|
_import(*_module_of(entry), entry)
|
||||||
root, dotted = _package_of(child)
|
except SyncError:
|
||||||
_import(root, dotted)
|
# A name collision is never somebody else's problem: it would
|
||||||
for info in pkgutil.walk_packages(
|
# put the wrong file behind a node.
|
||||||
sys.modules[dotted].__path__, f"{dotted}."
|
raise
|
||||||
):
|
except Exception as exc:
|
||||||
importlib.import_module(info.name)
|
# Importing a module runs it, so this is whatever the study
|
||||||
|
# does at its top level, and a walk meets every study.
|
||||||
|
if keep_going:
|
||||||
|
_say(f"warning: skipped {entry} — {type(exc).__name__}: {exc}")
|
||||||
|
continue
|
||||||
|
raise SyncError(
|
||||||
|
f"{entry} failed to import — {type(exc).__name__}: {exc}"
|
||||||
|
) from exc
|
||||||
return list(FLOWS.values())
|
return list(FLOWS.values())
|
||||||
|
|
||||||
|
|
||||||
@@ -404,7 +469,7 @@ def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
def _sync_first(client: Client) -> None:
|
def _sync_first(client: Client, targets: list[str] | None = None) -> None:
|
||||||
"""Upload what the working directory declares, before running it.
|
"""Upload what the working directory declares, before running it.
|
||||||
|
|
||||||
The reason a run exists is usually the edit that came before it, and
|
The reason a run exists is usually the edit that came before it, and
|
||||||
@@ -412,18 +477,24 @@ def _sync_first(client: Client) -> None:
|
|||||||
done. So `run` syncs by default — including the worker refresh, which is
|
done. So `run` syncs by default — including the worker refresh, which is
|
||||||
what makes an edit to your own package take effect at all.
|
what makes an edit to your own package take effect at all.
|
||||||
|
|
||||||
|
The whole directory, downwards: from a repository root the flow is usually
|
||||||
|
in a study folder below, and the alternative is syncing by hand and then
|
||||||
|
running with `--no-sync`. The upload is already a no-op for a flow nothing
|
||||||
|
changed in, so the cost is importing the other studies — which `--sync`
|
||||||
|
narrows when that is not free.
|
||||||
|
|
||||||
A directory that declares nothing is not an error: a flow drawn on the
|
A directory that declares nothing is not an error: a flow drawn on the
|
||||||
canvas is run the same way, and has nothing to upload.
|
canvas is run the same way, and has nothing to upload.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
flows = discover(["."])
|
flows = discover(targets or ["."], keep_going=True)
|
||||||
except (ImportError, SyncError) as exc:
|
except (ImportError, SyncError) as exc:
|
||||||
# Do not fail a run for a module the run may not even need.
|
# Do not fail a run for a module the run may not even need.
|
||||||
_say(f"warning: nothing synced — {exc}")
|
_say(f"warning: nothing synced — {exc}")
|
||||||
return
|
return
|
||||||
if not flows:
|
if not flows:
|
||||||
return
|
return
|
||||||
repo = repo_root(".")
|
repo = repo_root((targets or ["."])[0])
|
||||||
reports = sync(flows, client, origin=origin_of(repo))
|
reports = sync(flows, client, origin=origin_of(repo))
|
||||||
changed = [r for r in reports if not r.unchanged]
|
changed = [r for r in reports if not r.unchanged]
|
||||||
if changed:
|
if changed:
|
||||||
@@ -494,7 +565,7 @@ def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
|
|||||||
try:
|
try:
|
||||||
with _client_for(args) as client:
|
with _client_for(args) as client:
|
||||||
if not args.no_sync:
|
if not args.no_sync:
|
||||||
_sync_first(client)
|
_sync_first(client, args.sync)
|
||||||
stored = client.get_flow(args.flow)
|
stored = client.get_flow(args.flow)
|
||||||
if stored is None:
|
if stored is None:
|
||||||
return _fail(f"no flow '{args.flow}' on that engine")
|
return _fail(f"no flow '{args.flow}' on that engine")
|
||||||
@@ -884,7 +955,7 @@ def cmd_sweep(args: argparse.Namespace) -> int:
|
|||||||
try:
|
try:
|
||||||
with _client_for(args) as client:
|
with _client_for(args) as client:
|
||||||
if not args.no_sync:
|
if not args.no_sync:
|
||||||
_sync_first(client)
|
_sync_first(client, args.sync)
|
||||||
stored = client.get_flow(args.flow)
|
stored = client.get_flow(args.flow)
|
||||||
if stored is None:
|
if stored is None:
|
||||||
return _fail(f"no flow '{args.flow}' on that engine")
|
return _fail(f"no flow '{args.flow}' on that engine")
|
||||||
@@ -1157,6 +1228,13 @@ def add_parsers(subparsers: Any) -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="run what is already on the engine, without uploading first",
|
help="run what is already on the engine, without uploading first",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sync",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="PATH",
|
||||||
|
help="what to sync first (default: this directory, downwards)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-cache",
|
"--no-cache",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -1225,6 +1303,13 @@ def add_parsers(subparsers: Any) -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="run what is already on the engine, without uploading first",
|
help="run what is already on the engine, without uploading first",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sync",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="PATH",
|
||||||
|
help="what to sync first (default: this directory, downwards)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-cache",
|
"--no-cache",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|||||||
@@ -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 0.1.4" in _too_old(Old())
|
||||||
assert "engine is older" in _too_old(Ancient())
|
assert "engine is older" in _too_old(Ancient())
|
||||||
assert "pip install -U fluksio" 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
|
||||||
|
|||||||
+26
-4
@@ -39,11 +39,17 @@ On the first start it creates an admin account and prints its password **once**.
|
|||||||
Nothing else has to be running: no database server, no message broker, no
|
Nothing else has to be running: no database server, no message broker, no
|
||||||
Docker.
|
Docker.
|
||||||
|
|
||||||
|
The default port moves out of the way when something already has it — 8001,
|
||||||
|
8002, and so on — and says which one it took; the URL written to
|
||||||
|
`client.json` is the one it is actually on. A port you *asked* for is never
|
||||||
|
moved off: `--port 9000` on a taken 9000 fails, because something else is
|
||||||
|
there and you named it.
|
||||||
|
|
||||||
| Option | Default | What it does |
|
| Option | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--data-dir PATH` | `./.fluksio` (or `$FLUKSIO_HOME`) | where this installation keeps everything |
|
| `--data-dir PATH` | `./.fluksio` (or `$FLUKSIO_HOME`) | where this installation keeps everything |
|
||||||
| `--host HOST` | `127.0.0.1` | what to bind |
|
| `--host HOST` | `127.0.0.1` | what to bind |
|
||||||
| `--port PORT` | `8000` | what to listen on |
|
| `--port PORT` | `8000`, or the next free one | what to listen on |
|
||||||
| `--log-level LEVEL` | `info` | uvicorn's log level |
|
| `--log-level LEVEL` | `info` | uvicorn's log level |
|
||||||
| `--admin-email ADDR` | `admin@example.com` | the account created on first run |
|
| `--admin-email ADDR` | `admin@example.com` | the account created on first run |
|
||||||
| `--admin-password PW` | generated | set it instead of having one generated |
|
| `--admin-password PW` | generated | set it instead of having one generated |
|
||||||
@@ -152,6 +158,15 @@ each one with a generated import shim per node. A directory that is a package
|
|||||||
is walked; a dotted name is imported as it stands; nothing is loaded from a
|
is walked; a dotted name is imported as it stands; nothing is loaded from a
|
||||||
file path, because the shim has to import the same way.
|
file path, because the shim has to import the same way.
|
||||||
|
|
||||||
|
A plain directory is walked all the way down, so one folder per study —
|
||||||
|
`fluksio sync dev` over `dev/s1_baseline/study.py` — needs no naming. Hidden
|
||||||
|
directories, `__pycache__`, `node_modules` and virtualenvs are left alone. Two
|
||||||
|
files that would import under the same name are refused rather than
|
||||||
|
silently collapsed into one: Python keeps one module per name, and a node's
|
||||||
|
generated body imports by that name, so `dev/s1/study.py` and `dev/s2/study.py`
|
||||||
|
need an `__init__.py` each — making them `s1.study` and `s2.study` — or
|
||||||
|
different filenames.
|
||||||
|
|
||||||
| Flag | What it does |
|
| Flag | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--dry-run` | print the flow documents and shims, upload nothing |
|
| `--dry-run` | print the flow documents and shims, upload nothing |
|
||||||
@@ -182,9 +197,16 @@ upgraded its cache is keyed on the whole repository, as it was before. See
|
|||||||
fluksio run train --lr 0.05 --seed 7 [--wait]
|
fluksio run train --lr 0.05 --seed 7 [--wait]
|
||||||
```
|
```
|
||||||
|
|
||||||
Syncs the working directory, then submits a run — so the command after an edit
|
Syncs the working directory *and everything under it*, then submits a run — so
|
||||||
is this one and nothing else. Flags that are not its own are the flow's
|
the command after an edit is this one and nothing else, from the repository
|
||||||
inputs, typed by what the flow declares them as. `--wait` blocks until the run
|
root as readily as from the study's own folder. A study that will not import
|
||||||
|
is a warning rather than a stopped run; the upload is already a no-op for a
|
||||||
|
flow nothing changed in, so what the walk costs is importing the others.
|
||||||
|
`--sync dev/s1_baseline` (repeatable) narrows it to what you name when that
|
||||||
|
is not free, and `--no-sync` skips it entirely.
|
||||||
|
|
||||||
|
Flags that are not its own are the flow's inputs, typed by what the flow
|
||||||
|
declares them as. `--wait` blocks until the run
|
||||||
finishes and exits non-zero if it failed. `--follow` waits as well, and prints
|
finishes and exits non-zero if it failed. `--follow` waits as well, and prints
|
||||||
the numbers the run reports as they arrive:
|
the numbers the run reports as they arrive:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user