Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>
packaging {
fileName = "usage-example"
Expand Down Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -540,7 +540,7 @@ bundled. Every `buildPython<Variant><BuildType>` and the single host `buildPytho
--find-links <dir> typedpython==<v> pyrefly==<v>` (`@Input` versions, defaults `0.1.0` and `1.3.2`).
`<dir>` 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=
<group:artifact:version>` overrides it). It is resolved leniently from the project's own
repositories (python-multiplatform's `publishToMavenLocal` puts it in `mavenLocal()`), unzipped into
Expand Down Expand Up @@ -671,9 +671,15 @@ task output and `wasmJsProcessResources` wiring).
## 2. `tcl`: toolchain-lite

### 2.1 `tcl install <package>`
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 <package>`.

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/getting-started.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ <h2><span data-lang="en">3. Apply the plugin</span><span data-lang="ko">3. 플
}

python {
compileSdk = &quot;3.13&quot;
compileSdk = &quot;3.15.0&quot;
localLibraryPath = &quot;src/commonMain/python&quot;
packaging {
fileName = &quot;usage-example&quot;
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/guide-tcl.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ <h1><span data-lang="en">tcl for Python users</span><span data-lang="ko">Python

<section>
<h2><span data-lang="en">Install a package</span><span data-lang="ko">패키지 설치</span></h2>
<pre><code class="language-shell">uv tool install toolchain-lite # once 0.1.0 is on PyPI
<pre><code class="language-shell">uv tool install toolchain-lite
tcl install pythonx-compose
tcl --version

./gradlew :toolchain:runTcl --args=&quot;install pythonx-compose&quot; # from a checkout</code></pre>
<p data-lang="en"><code>tcl install &lt;package&gt;</code> looks for a <code>pyproject.toml</code> in the current directory and its parents. If there is none it runs <code>uv init --bare</code> first; then it adds the package with <code>uv add</code>, through the same pypackpack backend the Gradle plugin uses.</p><p data-lang="ko"><code>tcl install &lt;package&gt;</code> 는 현재 디렉터리와 그 상위에서 <code>pyproject.toml</code> 을 찾습니다. 없으면 먼저 <code>uv init --bare</code> 를 실행하고, Gradle 플러그인과 같은 pypackpack 백엔드로 <code>uv add</code> 를 해서 패키지를 추가합니다.</p>
<p data-lang="en"><code>tcl install &lt;package&gt;</code> uses the <code>pyproject.toml</code> 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 <code>uv init --bare --no-workspace</code> in the current directory first; then it adds the package with <code>uv add</code>, through the same pypackpack backend the Gradle plugin uses.</p><p data-lang="ko"><code>tcl install &lt;package&gt;</code> 는 현재 디렉터리의 <code>pyproject.toml</code> 을 씁니다. git 저장소 안이면 상위 디렉터리도 저장소 루트까지만 찾고 그 위로는 가지 않습니다. 저장소 밖이면 상위 디렉터리를 아예 찾지 않으므로, 위에 있는 관계없는 프로젝트를 고치는 일이 없습니다. 없으면 먼저 현재 디렉터리에서 <code>uv init --bare --no-workspace</code> 를 실행하고, Gradle 플러그인과 같은 pypackpack 백엔드로 <code>uv add</code> 를 해서 패키지를 추가합니다.</p>
<div class="table-wrap"><table><thead><tr><th><span data-lang="en">Invocation</span><span data-lang="ko">호출</span></th><th><span data-lang="en">Result</span><span data-lang="ko">결과</span></th></tr></thead><tbody><tr><td><code>tcl install pkg</code></td><td><span data-lang="en">exit 0, <code>uv</code> output on stdout</span><span data-lang="ko">종료 코드 0, stdout 에 <code>uv</code> 출력</span></td></tr><tr><td><code>tcl</code></td><td><span data-lang="en">exit 1, <code>Usage: tcl install &lt;package&gt;</code></span><span data-lang="ko">종료 코드 1, <code>Usage: tcl install &lt;package&gt;</code></span></td></tr><tr><td><code>tcl install</code></td><td><span data-lang="en">exit 1, missing argument</span><span data-lang="ko">종료 코드 1, 인자 누락</span></td></tr><tr><td><code>tcl other</code></td><td><span data-lang="en">exit 1, unknown command</span><span data-lang="ko">종료 코드 1, 알 수 없는 명령</span></td></tr></tbody></table></div>
<div class="note"><p data-lang="en"><code>install</code> is the only command, matching issue #1. <code>tcl</code> needs <code>uv</code> on <code>PATH</code> and network access to PyPI.</p><p data-lang="ko">명령은 이슈 #1 그대로 <code>install</code> 하나뿐입니다. <code>tcl</code> 은 <code>PATH</code> 의 <code>uv</code> 와 PyPI 네트워크 접근이 필요합니다.</p></div>
</section>
Expand Down
5 changes: 3 additions & 2 deletions docs/locale/README_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>
packaging {
fileName = "usage-example"
Expand Down Expand Up @@ -119,7 +119,8 @@ python {
Python 만 쓰나요? Gradle 빌드 없이:

```shell
./gradlew :toolchain:runTcl --args="install pythonx-compose"
uv tool install toolchain-lite
tcl install pythonx-compose
```

## 🧭 한눈에 보는 구조
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>` `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.
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import java.io.File
* uses the same). This is the one place both are written; `-Ptypedpython.gate=<group:artifact:version>`
* 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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion usage-example/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading