Skip to content

Scaffold

Opt-in launcher generator: generate_launchers, runnable as python -m duho.scaffold. Core duho never imports this module.

duho.scaffold

Opt-in launcher generator: emit a run-from-checkout launcher pair for an app.

Opt-in dev tool. Core duho never imports this module; you activate it explicitly with import duho.scaffold or python -m duho.scaffold. Its symbols are in this module's own __all__ but deliberately NOT on the top-level duho.__all__ -- it is a project-scaffolding helper, not runtime surface.

What it generates

For a target application laid out as <root>/bin/ + <root>/<libdir>/<app> (a package importable once <root>/<libdir> is on PYTHONPATH), :func:generate_launchers writes a matched launcher pair into <root>/bin/:

  • bin/<app> -- a POSIX sh wrapper that resolves its own directory, computes the app root (the parent of bin/), prepends <root>/<libdir> to PYTHONPATH, and execs "${PYTHON:-python3}" -m <app> "$@";
  • bin/<app>.cmd -- the Windows cousin doing the same via %~dp0 and %PYTHON% (default python), running %PYTHON% -m <app> %*.

Both launchers are small, dependency-free, and honor a PYTHON environment override so a caller can pin the interpreter. The generator writes plain files (it copies/emits text -- it never creates symlinks, which need privilege on Windows and add failure modes); the POSIX file additionally gets its executable bit set best-effort (skipped on platforms without chmod support).

By default an existing launcher is not clobbered -- pass overwrite=True (--force on the CLI) to rewrite a launcher a user may have customized.

The CLI dogfoods duho itself: python -m duho.scaffold <app> [--root DIR] [--libdir lib] [--python PY] [--force] is implemented as a :class:duho.Cmd.

generate_launchers(app, root, *, libdir='lib', python=None, overwrite=False)

Write a POSIX + Windows launcher pair for app into <root>/bin/.

Emits <root>/bin/<app> (POSIX sh) and <root>/bin/<app>.cmd (Windows), each of which puts <root>/<libdir> on PYTHONPATH and runs python -m <app> -- so the app runs from a checkout without being installed. The bin/ directory is created if missing. Returns the list of written paths (POSIX first, then .cmd).

Parameters

app: The app's importable module name (the <app> in python -m <app>). root: The app root directory (the parent of bin/); bin/ is created under it. libdir: The subdirectory under root holding the app package, prepended to PYTHONPATH (default "lib"; a src-layout app passes libdir="src"). python: Interpreter token baked in as the default (overridable at runtime via the PYTHON env var). None uses the per-flavor default (python3 for POSIX, python for Windows). overwrite: When False (default), refuse to clobber an existing launcher -- raising :class:FileExistsError naming the first conflict, so a user-customized launcher is never silently overwritten. True rewrites.

The generator writes plain files (never symlinks) and sets the POSIX launcher's executable bit best-effort (a no-op where the platform/filesystem doesn't support it).

Raises :class:ValueError for an app that is not a dotted ASCII identifier, a libdir that is not a relative, ..-free ASCII path free of quote/backtick/$/%/backslash/newline characters, or a python with the same forbidden characters -- all three are interpolated into generated shell/batch text, so a hyphenated/path-like/hostile value would otherwise produce a launcher that cannot work, escapes bin/, or executes unintended commands.

Source code in src/duho/scaffold.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def generate_launchers(
    app: str,
    root: _ty.Union[str, _Path],
    *,
    libdir: str = "lib",
    python: _ty.Optional[str] = None,
    overwrite: bool = False,
) -> _ty.List[_Path]:
    """Write a POSIX + Windows launcher pair for ``app`` into ``<root>/bin/``.

    Emits ``<root>/bin/<app>`` (POSIX ``sh``) and ``<root>/bin/<app>.cmd``
    (Windows), each of which puts ``<root>/<libdir>`` on ``PYTHONPATH`` and runs
    ``python -m <app>`` -- so the app runs from a checkout without being
    installed. The ``bin/`` directory is created if missing. Returns the list of
    written paths (POSIX first, then ``.cmd``).

    Parameters
    ----------
    app:
        The app's importable module name (the ``<app>`` in ``python -m <app>``).
    root:
        The app root directory (the parent of ``bin/``); ``bin/`` is created
        under it.
    libdir:
        The subdirectory under ``root`` holding the app package, prepended to
        ``PYTHONPATH`` (default ``"lib"``; a ``src``-layout app passes
        ``libdir="src"``).
    python:
        Interpreter token baked in as the default (overridable at runtime via
        the ``PYTHON`` env var). ``None`` uses the per-flavor default
        (``python3`` for POSIX, ``python`` for Windows).
    overwrite:
        When ``False`` (default), refuse to clobber an existing launcher --
        raising :class:`FileExistsError` naming the first conflict, so a
        user-customized launcher is never silently overwritten. ``True`` rewrites.

    The generator writes **plain files** (never symlinks) and sets the POSIX
    launcher's executable bit best-effort (a no-op where the platform/filesystem
    doesn't support it).

    Raises :class:`ValueError` for an ``app`` that is not a dotted ASCII
    identifier, a ``libdir`` that is not a relative, ``..``-free ASCII path
    free of quote/backtick/``$``/``%``/backslash/newline characters, or a
    ``python`` with the same forbidden characters -- all three are
    interpolated into generated shell/batch text, so a
    hyphenated/path-like/hostile value would otherwise produce a launcher that
    cannot work, escapes ``bin/``, or executes unintended commands.
    """
    _validate_app(app)
    _validate_interpolated(libdir, "libdir", path_like=True)
    if python is not None:
        _validate_interpolated(python, "python")

    root_path = _Path(root)
    bindir = root_path / "bin"

    posix_path = bindir / app
    windows_path = bindir / (app + ".cmd")

    if not overwrite:
        for existing in (posix_path, windows_path):
            if existing.exists():
                raise FileExistsError(
                    "duho.scaffold: launcher %s already exists; pass "
                    "overwrite=True (or --force) to replace it" % existing
                )

    bindir.mkdir(parents=True, exist_ok=True)

    posix_python = python if python is not None else _DEFAULT_POSIX_PYTHON
    windows_python = python if python is not None else _DEFAULT_WINDOWS_PYTHON

    # Write "\n" newlines regardless of host (``newline=""`` disables
    # translation) so a script generated on Windows runs on POSIX. Use ``open``:
    # ``Path.write_text(newline=...)`` needs 3.10+.
    with posix_path.open("w", encoding="utf-8", newline="") as fh:
        fh.write(_posix_launcher(app, libdir, posix_python))
    _make_executable(posix_path)

    # Windows launcher: written with "\r\n" (the text already embeds CRLF, and
    # newline="" keeps Python from translating it further).
    with windows_path.open("w", encoding="utf-8", newline="") as fh:
        fh.write(_windows_launcher(app, libdir, windows_python))

    return [posix_path, windows_path]

main(argv=None)

python -m duho.scaffold entry point: dispatch :class:ScaffoldCmd.

Source code in src/duho/scaffold.py
339
340
341
def main(argv: _ty.Optional[_ty.Sequence[str]] = None) -> int:
    """``python -m duho.scaffold`` entry point: dispatch :class:`ScaffoldCmd`."""
    return _main(ScaffoldCmd, argv)

CLI

duho.scaffold.ScaffoldCmd is the duho.Cli command backing python -m duho.scaffold; run python -m duho.scaffold --help for its full, generated usage (<app> [--root DIR] [--libdir lib] [--python PY] [--force]).