diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 25dfe74..b216b1f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,7 +123,7 @@ jobs: # usage-example applies three things from mavenLocal: packpack (through the plugin), the # toolchain plugin, and python-multiplatform's library and bindings plugin. Versions pinned in - # usage-example/gradle/libs.versions.toml (3.15.0-alpha02). + # usage-example/gradle/libs.versions.toml (3.15.0-alpha03). - name: Publish packpack, the toolchain plugin and python-multiplatform to mavenLocal run: | (cd .ci/pypackpack && ./gradlew :packpack:publishToMavenLocal --console=plain) diff --git a/AGENTS.md b/AGENTS.md index 3e924cd..e067b6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,7 +246,8 @@ Prerequisites: `org.thisisthepy.python.multiplatform:packpack:0.1.0` in `~/.m2` `pypackpack` repository), `uv` on `PATH`, and network access: `InstallDependenciesTaskTest` and `InstallerTest` run a real `uv add` against PyPI. `usage-example` additionally needs an Android SDK (`ANDROID_HOME`) and python-multiplatform in `~/.m2`: from a python-multiplatform checkout, -`./gradlew publishAllToMavenLocal` (`org.thisisthepy:python-multiplatform*` and bindings plugin `3.15.0-alpha02`). +`./gradlew publishAllToMavenLocal` (`org.thisisthepy:python-multiplatform*` and bindings plugin `3.15.0-alpha03`). +That version must equal `DEFAULT_TYPEDPYTHON_GATE` (`toolchain/.../typedpython/TypedpythonGate.kt`): bump both together. Run each module separately (rule 8), with output to a file under `.cache/`: diff --git a/README.md b/README.md index 0e1a225..4057a1a 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ plugins { } python { - compileSdk = "3.13" + compileSdk = "3.15.0" // the CPython python-multiplatform provides localLibraryPath = "src/commonMain/python" // a pypackpack package: pyproject.toml + src/main/ packaging { fileName = "usage-example" @@ -120,7 +120,8 @@ python { Python only? Skip Gradle builds entirely: ```shell -./gradlew :toolchain:runTcl --args="install pythonx-compose" +uv tool install toolchain-lite +tcl install pythonx-compose ``` ## 🧭 Architecture at a glance diff --git a/docs/SPEC.md b/docs/SPEC.md index 7ea3f22..249f1f4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -61,7 +61,7 @@ Test: `PythonSdkTest` (`a CPython before 3_15 is refused as a GIL build, inside **Temporary exception: `3.15.0` without `t` (`PY3_15_0`).** It names python-multiplatform's GIL build, which is what python-multiplatform publishes today: its `gradle.properties` has `pythonFreeThreaded=false`, -its artifact version (`3.15.0-alpha02`) carries no flavour, and Android and iOS have no 3.15t build yet +its artifact version (`3.15.0-alpha03`) carries no flavour, and Android and iOS have no 3.15t build yet (python-multiplatform #163). Selecting `cp315t` wheels for that runtime would install wheels it cannot load. The exception is removed, and `3.15.0` then means `3.15.0t`, when python-multiplatform publishes free-threaded as its default with the flavour in its version or metadata, and #163 lands; #76 tracks it. @@ -540,7 +540,7 @@ bundled. Every `buildPython` and the single host `buildPytho --find-links typedpython== pyrefly==` (`@Input` versions, defaults `0.1.0` and `1.3.2`). `` must hold both wheels for the platform; nothing comes from an index. 2. The **gate artifact**, by default: the `typedpython-gate` zip python-multiplatform-ksp publishes beside - its processor jar, `org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha02:typedpython-gate@zip` + its processor jar, `org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha03:typedpython-gate@zip` (`DEFAULT_TYPEDPYTHON_GATE`, the one place its group and version are written; `-Ptypedpython.gate= ` overrides it). It is resolved leniently from the project's own repositories (python-multiplatform's `publishToMavenLocal` puts it in `mavenLocal()`), unzipped into @@ -671,9 +671,15 @@ task output and `wasmJsProcessResources` wiring). ## 2. `tcl`: toolchain-lite ### 2.1 `tcl install ` -Finds the nearest `pyproject.toml` at or above the working directory. If there is none, runs -`uv init --bare` there first (through `pypackpack`'s `BackendInterface.initProject`), then -`addDependencies` for the package. Exit code 0 on success and the `uv` output on stdout; 1 with a +Finds the project to add to without ever editing an unrelated ancestor (`findProjectRoot`): the working +directory's own `pyproject.toml` always counts; inside a git repository (a directory holding a `.git` +directory, or a `.git` file for a worktree) the search walks up to the nearest `pyproject.toml` and stops at +the repository root; outside any repository no parent is searched. If no project qualifies, runs +`uv init --bare --no-workspace` in the working directory first (through `pypackpack`'s +`BackendInterface.initProject`; `--no-workspace` keeps `uv` from adding the new project as a member of an +ancestor's workspace, which would write into that ancestor's `pyproject.toml`), then `addDependencies` for +the package. Inside a repository whose root has a `pyproject.toml`, a subdirectory without one still adds +to the root's project, as `uv add` itself would. Exit code 0 on success and the `uv` output on stdout; 1 with a message on stderr for a missing argument, an unknown command or a failed install. No arguments prints `Usage: tcl install `. diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index 542960f..3da9e8c 100644 --- a/docs/guide/getting-started.html +++ b/docs/guide/getting-started.html @@ -56,7 +56,7 @@

3. Apply the plugin3. ν”Œ } python { - compileSdk = "3.13" + compileSdk = "3.15.0" localLibraryPath = "src/commonMain/python" packaging { fileName = "usage-example" diff --git a/docs/guide/guide-tcl.html b/docs/guide/guide-tcl.html index ba9df2f..4aba534 100644 --- a/docs/guide/guide-tcl.html +++ b/docs/guide/guide-tcl.html @@ -28,12 +28,12 @@

tcl for Python usersPython

Install a packageνŒ¨ν‚€μ§€ μ„€μΉ˜

-
uv tool install toolchain-lite    # once 0.1.0 is on PyPI
+
uv tool install toolchain-lite
 tcl install pythonx-compose
 tcl --version
 
 ./gradlew :toolchain:runTcl --args="install pythonx-compose"    # from a checkout
-

tcl install <package> looks for a pyproject.toml in the current directory and its parents. If there is none it runs uv init --bare first; then it adds the package with uv add, through the same pypackpack backend the Gradle plugin uses.

tcl install <package> λŠ” ν˜„μž¬ 디렉터리와 κ·Έ μƒμœ„μ—μ„œ pyproject.toml 을 μ°ΎμŠ΅λ‹ˆλ‹€. μ—†μœΌλ©΄ λ¨Όμ € uv init --bare λ₯Ό μ‹€ν–‰ν•˜κ³ , Gradle ν”ŒλŸ¬κ·ΈμΈκ³Ό 같은 pypackpack λ°±μ—”λ“œλ‘œ uv add λ₯Ό ν•΄μ„œ νŒ¨ν‚€μ§€λ₯Ό μΆ”κ°€ν•©λ‹ˆλ‹€.

+

tcl install <package> uses the pyproject.toml in the current directory. Inside a git repository it also looks in the parent directories, up to the repository root and never above it; outside a repository it does not look in parents at all, so an unrelated project above you is never edited. If there is none it runs uv init --bare --no-workspace in the current directory first; then it adds the package with uv add, through the same pypackpack backend the Gradle plugin uses.

tcl install <package> λŠ” ν˜„μž¬ λ””λ ‰ν„°λ¦¬μ˜ pyproject.toml 을 μ”λ‹ˆλ‹€. git μ €μž₯μ†Œ μ•ˆμ΄λ©΄ μƒμœ„ 디렉터리도 μ €μž₯μ†Œ λ£¨νŠΈκΉŒμ§€λ§Œ μ°Ύκ³  κ·Έ μœ„λ‘œλŠ” κ°€μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. μ €μž₯μ†Œ 밖이면 μƒμœ„ 디렉터리λ₯Ό μ•„μ˜ˆ μ°Ύμ§€ μ•ŠμœΌλ―€λ‘œ, μœ„μ— μžˆλŠ” κ΄€κ³„μ—†λŠ” ν”„λ‘œμ νŠΈλ₯Ό κ³ μΉ˜λŠ” 일이 μ—†μŠ΅λ‹ˆλ‹€. μ—†μœΌλ©΄ λ¨Όμ € ν˜„μž¬ λ””λ ‰ν„°λ¦¬μ—μ„œ uv init --bare --no-workspace λ₯Ό μ‹€ν–‰ν•˜κ³ , Gradle ν”ŒλŸ¬κ·ΈμΈκ³Ό 같은 pypackpack λ°±μ—”λ“œλ‘œ uv add λ₯Ό ν•΄μ„œ νŒ¨ν‚€μ§€λ₯Ό μΆ”κ°€ν•©λ‹ˆλ‹€.

Invocation호좜Result결과
tcl install pkgexit 0, uv output on stdoutμ’…λ£Œ μ½”λ“œ 0, stdout 에 uv 좜λ ₯
tclexit 1, Usage: tcl install <package>μ’…λ£Œ μ½”λ“œ 1, Usage: tcl install <package>
tcl installexit 1, missing argumentμ’…λ£Œ μ½”λ“œ 1, 인자 λˆ„λ½
tcl otherexit 1, unknown commandμ’…λ£Œ μ½”λ“œ 1, μ•Œ 수 μ—†λŠ” λͺ…λ Ή

install is the only command, matching issue #1. tcl needs uv on PATH and network access to PyPI.

λͺ…령은 이슈 #1 κ·ΈλŒ€λ‘œ install ν•˜λ‚˜λΏμž…λ‹ˆλ‹€. tcl 은 PATH 의 uv 와 PyPI λ„€νŠΈμ›Œν¬ 접근이 ν•„μš”ν•©λ‹ˆλ‹€.

diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md index 86118b8..bbc7a49 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -84,7 +84,7 @@ plugins { } python { - compileSdk = "3.13" + compileSdk = "3.15.0" // python-multiplatform 이 μ œκ³΅ν•˜λŠ” CPython localLibraryPath = "src/commonMain/python" // pypackpack νŒ¨ν‚€μ§€: pyproject.toml + src/main/ packaging { fileName = "usage-example" @@ -119,7 +119,8 @@ python { Python 만 μ“°λ‚˜μš”? Gradle λΉŒλ“œ 없이: ```shell -./gradlew :toolchain:runTcl --args="install pythonx-compose" +uv tool install toolchain-lite +tcl install pythonx-compose ``` ## 🧭 ν•œλˆˆμ— λ³΄λŠ” ꡬ쑰 diff --git a/toolchain/src/cliMain/kotlin/org/thisisthepy/python/multiplatform/tcl/Installer.kt b/toolchain/src/cliMain/kotlin/org/thisisthepy/python/multiplatform/tcl/Installer.kt index b483bfc..2d19d2c 100644 --- a/toolchain/src/cliMain/kotlin/org/thisisthepy/python/multiplatform/tcl/Installer.kt +++ b/toolchain/src/cliMain/kotlin/org/thisisthepy/python/multiplatform/tcl/Installer.kt @@ -7,16 +7,32 @@ import java.io.File private const val PYPROJECT_FILE = "pyproject.toml" +private const val GIT_ENTRY = ".git" + /** - * Finds the `pypackpack`/`uv` project root by walking up from [startDir] looking for a - * `pyproject.toml` -- mirrors `pypackpack`'s own `findProjectRoot` - * (`packpack/utils/Workspace.kt`), reimplemented here rather than reused because that function is - * `internal` to the `packpack` Gradle module and is not part of its published API surface. + * Finds the project `tcl install` adds to, starting at [startDir], without ever reaching an + * unrelated ancestor project: + * + * - [startDir]'s own `pyproject.toml` always counts. + * - Inside a git repository (a directory holding a `.git` directory, or a `.git` file for a + * worktree), the search walks up to the nearest `pyproject.toml`, and stops at the repository + * root, which is the last directory checked. A `pyproject.toml` above the repository belongs to + * another project. + * - Outside any repository there is no boundary to trust, so only [startDir] counts: a stray + * `pyproject.toml` in a parent or the home directory is never edited. + * + * Returns null when no project qualifies; [Installer.install] then initializes one in [startDir]. */ fun findProjectRoot(startDir: File): File? { - var current: File? = startDir.canonicalFile - while (current != null) { + val start = startDir.canonicalFile + if (File(start, PYPROJECT_FILE).isFile) return start + + val repositoryRoot = generateSequence(start) { it.parentFile }.firstOrNull { File(it, GIT_ENTRY).exists() } + ?: return null + var current: File? = start.parentFile + while (current != null && current.path.length >= repositoryRoot.path.length) { if (File(current, PYPROJECT_FILE).isFile) return current + if (current == repositoryRoot) break current = current.parentFile } return null @@ -45,7 +61,7 @@ class Installer( ) { /** * Adds [packageName] as a dependency of the project rooted at (or above) [workingDir], - * initializing a fresh `pyproject.toml` there first via `uv init --bare` if [findProjectRoot] + * initializing a fresh `pyproject.toml` there first via `uv init --bare --no-workspace` if [findProjectRoot] * finds none. Returns the same `Result` `pypackpack`'s backend does: `uv`'s own * combined stdout/stderr on success, or the failure it hit -- including a failed `initProject`, * which short-circuits before `addDependencies` is ever attempted. @@ -58,9 +74,16 @@ class Installer( val existingRoot = findProjectRoot(workingDir) if (existingRoot == null) { - // `--bare` matches what `pypackpack init` itself does (`docs/SPEC.md` "Project - // creation"): only `pyproject.toml` is generated by `uv`, nothing else assumed. - val initResult = backend.initProject(path = null, extraArgs = mapOf("bare" to ""), workingDir = workingDir) + // `--bare` matches what `pypackpack init` itself does (pypackpack's `docs/SPEC.md` + // "Project creation"): only `pyproject.toml` is generated by `uv`, nothing else assumed. + // `--no-workspace` keeps `uv` from registering the new project as a member of an + // ancestor's workspace, which would write `[tool.uv.workspace]` into that ancestor. + val initResult = + backend.initProject( + path = null, + extraArgs = mapOf("bare" to "", "no-workspace" to ""), + workingDir = workingDir, + ) if (initResult.isFailure) return initResult } diff --git a/toolchain/src/cliTest/kotlin/org/thisisthepy/python/multiplatform/tcl/InstallerTest.kt b/toolchain/src/cliTest/kotlin/org/thisisthepy/python/multiplatform/tcl/InstallerTest.kt index a6f42cd..1d1d623 100644 --- a/toolchain/src/cliTest/kotlin/org/thisisthepy/python/multiplatform/tcl/InstallerTest.kt +++ b/toolchain/src/cliTest/kotlin/org/thisisthepy/python/multiplatform/tcl/InstallerTest.kt @@ -3,7 +3,9 @@ package org.thisisthepy.python.multiplatform.tcl import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -61,14 +63,67 @@ class InstallerTest { } @Test - fun `findProjectRoot walks up from a nested directory to the pyproject toml`() { + fun `findProjectRoot walks up from a nested directory to the pyproject toml of the same repository`() { val root = createTempDirectory("tcl-find-root").toFile() + File(root, ".git").mkdirs() File(root, "pyproject.toml").writeText("[project]\nname = \"x\"\n") val nested = File(root, "a/b/c").apply { mkdirs() } assertTrue(findProjectRoot(nested) == root.canonicalFile) } + @Test + fun `findProjectRoot uses the working directory's own pyproject toml`() { + val dir = createTempDirectory("tcl-find-root-cwd").toFile() + File(dir, "pyproject.toml").writeText("[project]\nname = \"x\"\n") + + assertTrue(findProjectRoot(dir) == dir.canonicalFile) + } + + @Test + fun `findProjectRoot does not cross a git boundary into an unrelated parent project`() { + val parent = createTempDirectory("tcl-find-root-git-boundary").toFile() + File(parent, "pyproject.toml").writeText("[project]\nname = \"unrelated\"\n") + val repo = File(parent, "repo").apply { mkdirs() } + // A worktree's `.git` is a file, not a directory: both mark the boundary. + File(repo, ".git").writeText("gitdir: elsewhere\n") + val nested = File(repo, "src").apply { mkdirs() } + + assertNull(findProjectRoot(nested), "the parent's pyproject.toml lies outside the repository") + } + + @Test + fun `findProjectRoot outside any repository does not take an ancestor's pyproject toml`() { + val parent = createTempDirectory("tcl-find-root-no-repo").toFile() + File(parent, "pyproject.toml").writeText("[project]\nname = \"unrelated\"\n") + val child = File(parent, "child").apply { mkdirs() } + + assertNull(findProjectRoot(child), "with no repository boundary, only the working directory counts") + } + + @Test + fun `install below an unrelated project creates its own project and leaves the ancestor untouched`() { + val parent = createTempDirectory("tcl-install-unrelated-ancestor").toFile() + val parentPyproject = + """ + [project] + name = "unrelated-ancestor" + version = "0.0.1" + requires-python = ">=3.13" + dependencies = [] + """.trimIndent() + File(parent, "pyproject.toml").writeText(parentPyproject) + val child = File(parent, "child").apply { mkdirs() } + + val result = installBlocking("six", child) + + assertTrue(result.isSuccess, "install should succeed: ${result.exceptionOrNull()}") + assertEquals(parentPyproject, File(parent, "pyproject.toml").readText(), "the ancestor project must not be edited") + val own = File(child, "pyproject.toml") + assertTrue(own.isFile, "install should have initialized a project in the working directory") + assertTrue(own.readText().contains("six"), "the new project should record the dependency") + } + @Test fun `findProjectRoot returns null when nothing is found above it`() { val dir = createTempDirectory("tcl-find-root-none").toFile() diff --git a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt index fdce0ac..ba311f7 100644 --- a/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt +++ b/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGate.kt @@ -10,7 +10,7 @@ import java.io.File * uses the same). This is the one place both are written; `-Ptypedpython.gate=` * overrides it. */ -const val DEFAULT_TYPEDPYTHON_GATE = "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha02" +const val DEFAULT_TYPEDPYTHON_GATE = "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha03" /** Project property overriding [DEFAULT_TYPEDPYTHON_GATE]. */ const val TYPEDPYTHON_GATE_PROPERTY = "typedpython.gate" diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt index ef36566..4229335 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateArtifactTest.kt @@ -20,11 +20,14 @@ import kotlin.test.assertTrue * comes from PyPI). Without the zip these tests are skipped with that reason, as on CI. */ class TypedpythonGateArtifactTest { - private val mavenLocalZip = File( - System.getProperty("user.home"), - ".m2/repository/org/thisisthepy/python-multiplatform-ksp/3.15.0-alpha02/" + - "python-multiplatform-ksp-3.15.0-alpha02-typedpython-gate.zip", - ) + // Derived from DEFAULT_TYPEDPYTHON_GATE so a version bump cannot leave this test probing an old path. + private val mavenLocalZip = DEFAULT_TYPEDPYTHON_GATE.split(':').let { (group, artifact, version) -> + File( + System.getProperty("user.home"), + ".m2/repository/${group.replace('.', '/')}/$artifact/$version/" + + "$artifact-$version-$TYPEDPYTHON_GATE_CLASSIFIER.zip", + ) + } @BeforeTest fun requireGate() { diff --git a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt index 285cde8..327c204 100644 --- a/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt +++ b/toolchain/src/test/kotlin/org/thisisthepy/python/multiplatform/toolchain/typedpython/TypedpythonGateTest.kt @@ -41,9 +41,9 @@ class TypedpythonGateTest { @Test fun `the gate is the typedpython-gate zip of python-multiplatform-ksp, from one constant`() { - assertEquals("org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha02", DEFAULT_TYPEDPYTHON_GATE) + assertEquals("org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha03", DEFAULT_TYPEDPYTHON_GATE) assertEquals( - "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha02:typedpython-gate@zip", + "org.thisisthepy:python-multiplatform-ksp:3.15.0-alpha03:typedpython-gate@zip", typedpythonGateNotation(DEFAULT_TYPEDPYTHON_GATE), ) assertEquals("typedpython.gate", TYPEDPYTHON_GATE_PROPERTY) diff --git a/usage-example/gradle/libs.versions.toml b/usage-example/gradle/libs.versions.toml index 8c67728..d00bb42 100644 --- a/usage-example/gradle/libs.versions.toml +++ b/usage-example/gradle/libs.versions.toml @@ -13,7 +13,8 @@ compose-plugin = "1.11.1-ext.3" kotlin = "2.4.20-Beta2" kotlinx-coroutines = "1.10.1" # python-multiplatform's library, KSP and bindings plugin share one version (its #70, #95, #318, #333). -python-multiplatform = "3.15.0-alpha02" +# Keep it equal to the toolchain plugin's DEFAULT_TYPEDPYTHON_GATE. +python-multiplatform = "3.15.0-alpha03" # The toolchain plugin, from ../toolchain via mavenLocal (`./gradlew :toolchain:publishToMavenLocal`). toolchain = "1.0.0-alpha"