Skip to content

Commit 43f2d62

Browse files
mikolalysenkoclaude
andcommitted
feat(setup,release): commit the socket-patch[hook] extra; publish socket-patch-hook to PyPI
setup now commits the `socket-patch[hook]` extra (one line that pulls both the CLI and the socket-patch-hook .pth wheel) instead of a bare socket-patch-hook dep. PEP 621 / requirements.txt get the literal `socket-patch[hook]`; classic Poetry can't express an extra as a bare key, so edit.rs writes the equivalent `socket-patch = { extras = ["hook"] }`, merged into any existing socket-patch dep with its version/source preserved. The separate socket-patch-hook wheel remains the irreducible .pth carrier behind the extra (an extra can only pull a dependency, not ship a file); users never reference it directly. Release: publish socket-patch and socket-patch-hook as separate PyPI projects, each from its own dist dir so trusted publishing mints a correctly-scoped OIDC token per project. (socket-patch-hook needs its own pending trusted publisher registered on PyPI before the first release.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b527427 commit 43f2d62

5 files changed

Lines changed: 143 additions & 67 deletions

File tree

.github/workflows/release.yml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -421,12 +421,27 @@ jobs:
421421
- name: Copy README for PyPI package
422422
run: cp README.md pypi/socket-patch/README.md
423423

424-
- name: Build platform wheels
424+
- name: Build wheels (platform socket-patch + pure-python socket-patch-hook)
425425
run: |
426426
VERSION="${{ needs.version.outputs.version }}"
427+
# Builds the platform-tagged socket-patch wheels AND the pure-python
428+
# socket-patch-hook wheel (the .pth carrier behind `socket-patch[hook]`).
427429
python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist
428-
429-
- name: Publish to PyPI
430+
# socket-patch and socket-patch-hook are two distinct PyPI projects.
431+
# Publish each from its own dir so trusted publishing mints an OIDC
432+
# token scoped to the right project (one upload spanning both projects
433+
# can be rejected). Each project needs its own trusted publisher on
434+
# PyPI; register a "pending" publisher for socket-patch-hook before the
435+
# first release (repo + workflow `release.yml` + this environment).
436+
mkdir -p dist-hook
437+
mv dist/socket_patch_hook-*.whl dist-hook/
438+
439+
- name: Publish socket-patch to PyPI
430440
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
431441
with:
432442
packages-dir: dist/
443+
444+
- name: Publish socket-patch-hook to PyPI
445+
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
446+
with:
447+
packages-dir: dist-hook/

crates/socket-patch-cli/tests/setup_pth_invariants.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ fn pip_requirements_gets_hook_dep() {
5050
assert_eq!(entry["kind"], "pth");
5151

5252
let req = read(&tmp.path().join("requirements.txt"));
53-
assert!(req.contains("socket-patch-hook"), "got:\n{req}");
53+
assert!(req.contains("socket-patch[hook]"), "got:\n{req}");
5454
assert!(req.contains("requests==2.31.0"), "must preserve existing deps");
5555

5656
// The committed dependency is the source of truth — no separate marker file.
@@ -72,7 +72,7 @@ fn uv_pyproject_array_edited_and_format_preserved() {
7272
assert_eq!(v["pythonPackageManager"], "uv");
7373

7474
let py = read(&tmp.path().join("pyproject.toml"));
75-
assert!(py.contains("socket-patch-hook"));
75+
assert!(py.contains("socket-patch[hook]"));
7676
assert!(py.contains("[tool.uv]"), "unrelated tables preserved");
7777
assert!(py.contains("name = \"x\""));
7878
}
@@ -88,7 +88,7 @@ fn idempotent_second_run_reports_already_configured() {
8888
assert_eq!(v["status"], "already_configured");
8989
let req = read(&tmp.path().join("requirements.txt"));
9090
assert_eq!(
91-
req.matches("socket-patch-hook").count(),
91+
req.matches("socket-patch[hook]").count(),
9292
1,
9393
"must not duplicate the hook dependency"
9494
);
@@ -120,7 +120,7 @@ fn remove_reverses_dep() {
120120
let (code, v) = run_setup(tmp.path(), &["--remove"]);
121121
assert_eq!(code, 0, "payload={v}");
122122
let req = read(&tmp.path().join("requirements.txt"));
123-
assert!(!req.contains("socket-patch-hook"), "got:\n{req}");
123+
assert!(!req.contains("socket-patch[hook]"), "got:\n{req}");
124124
assert!(req.contains("requests"));
125125
}
126126

@@ -149,7 +149,7 @@ fn polyglot_configures_both_npm_and_python() {
149149
assert!(kinds.contains(&"pth"));
150150

151151
assert!(read(&tmp.path().join("package.json")).contains("socket-patch"));
152-
assert!(read(&tmp.path().join("pyproject.toml")).contains("socket-patch-hook"));
152+
assert!(read(&tmp.path().join("pyproject.toml")).contains("socket-patch[hook]"));
153153
}
154154

155155
#[test]

crates/socket-patch-core/src/pth_hook/detect.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
33
use std::path::Path;
44

5-
/// The dependency `setup` adds to activate the hook: the standalone, version-
6-
/// agnostic hook wheel (it has no dependency on the CLI — the hook runs whatever
7-
/// `socket-patch` is on PATH). A bare token so the committed line never needs a
8-
/// version bump.
9-
pub const HOOK_DEP: &str = "socket-patch-hook";
5+
/// The dependency `setup` adds (PEP 508 form, used for `requirements.txt` and
6+
/// PEP 621 `[project].dependencies`): the `socket-patch[hook]` extra, which
7+
/// pulls both the socket-patch CLI and the socket-patch-hook wheel (the `.pth`
8+
/// carrier). A single, familiar line. Classic Poetry can't express an extra as
9+
/// a bare key, so [`super::edit`] emits the equivalent
10+
/// `socket-patch = { extras = ["hook"] }` there instead.
11+
pub const HOOK_DEP: &str = "socket-patch[hook]";
1012

1113
/// Substrings (space-insensitive, lower-cased) that mean the hook is already
12-
/// declared — the standalone wheel, the `socket-patch[hook]` convenience extra,
13-
/// or the underscore spelling.
14-
const HOOK_MARKERS: &[&str] = &["socket-patch-hook", "socket_patch_hook", "socket-patch[hook]"];
14+
/// declared — the `socket-patch[hook]` extra, the standalone wheel, or the
15+
/// underscore spelling. (The Poetry `extras = ["hook"]` form is detected
16+
/// structurally by [`super::edit`], not by this textual check.)
17+
const HOOK_MARKERS: &[&str] = &["socket-patch[hook]", "socket-patch-hook", "socket_patch_hook"];
1518

1619
/// Which Python dependency-management style a project uses. Drives both which
1720
/// manifest/table `setup` edits and which lockfile (if any) to refresh.

crates/socket-patch-core/src/pth_hook/edit.rs

Lines changed: 101 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
1515
use std::path::Path;
1616
use tokio::fs;
17-
use toml_edit::{Array, DocumentMut, Item, Table, Value};
17+
use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
1818

1919
use super::detect::{deps_contain_hook, spec_is_hook, HOOK_DEP};
2020

@@ -287,13 +287,33 @@ fn poetry_add(doc: &mut DocumentMut) -> Result<bool, String> {
287287
let poetry = ensure_table(tool, "poetry", true)?;
288288
let deps = ensure_table(poetry, "dependencies", false)?;
289289

290-
// The hook is a standalone, version-agnostic dependency — add it as its own
291-
// key rather than mutating the user's `socket-patch` entry. `"*"` because
292-
// the hook needs no specific version (it runs whatever CLI is on PATH).
290+
// Classic Poetry can't express `socket-patch[hook]` as a key, so declare
291+
// the equivalent: `socket-patch` carrying the `hook` extra. Already wired
292+
// if a bare `socket-patch-hook` key exists or the extra is already present.
293293
if deps.contains_key("socket-patch-hook") {
294294
return Ok(false);
295295
}
296-
deps.insert("socket-patch-hook", Item::Value(Value::from("*")));
296+
if let Some(item) = deps.get_mut("socket-patch") {
297+
if item_has_hook_extra(item) {
298+
return Ok(false);
299+
}
300+
// An existing `socket-patch` dep (bare string or a table): merge the
301+
// `hook` extra in place, preserving its version / source / markers.
302+
if let Some(tbl) = item.as_table_like_mut() {
303+
let mut extras = tbl
304+
.get("extras")
305+
.and_then(Item::as_array)
306+
.cloned()
307+
.unwrap_or_default();
308+
extras.push("hook");
309+
tbl.insert("extras", Item::Value(Value::Array(extras)));
310+
} else {
311+
let version = item.as_str().map(str::to_string).unwrap_or_else(|| "*".to_string());
312+
deps.insert("socket-patch", Item::Value(hook_inline_table(&version)));
313+
}
314+
return Ok(true);
315+
}
316+
deps.insert("socket-patch", Item::Value(hook_inline_table("*")));
297317
Ok(true)
298318
}
299319

@@ -309,7 +329,47 @@ fn poetry_remove(doc: &mut DocumentMut) -> bool {
309329
Some(d) => d,
310330
None => return false,
311331
};
312-
deps.remove("socket-patch-hook").is_some()
332+
333+
let mut changed = false;
334+
// Drop a legacy bare `socket-patch-hook` key if present.
335+
if deps.remove("socket-patch-hook").is_some() {
336+
changed = true;
337+
}
338+
// Strip the `hook` extra from a `socket-patch` dep table, leaving the rest
339+
// of the spec intact.
340+
if let Some(tbl) = deps.get_mut("socket-patch").and_then(Item::as_table_like_mut) {
341+
if let Some(extras) = tbl.get_mut("extras").and_then(Item::as_array_mut) {
342+
let before = extras.len();
343+
extras.retain(|v| v.as_str() != Some("hook"));
344+
if extras.len() != before {
345+
changed = true;
346+
}
347+
if extras.is_empty() {
348+
tbl.remove("extras");
349+
}
350+
}
351+
}
352+
changed
353+
}
354+
355+
/// Build `{ version = "<v>", extras = ["hook"] }`.
356+
fn hook_inline_table(version: &str) -> Value {
357+
let mut it = InlineTable::new();
358+
it.insert("version", Value::from(version));
359+
let mut extras = Array::new();
360+
extras.push("hook");
361+
it.insert("extras", Value::Array(extras));
362+
Value::InlineTable(it)
363+
}
364+
365+
/// True if a dependency item (inline table or sub-table) already carries the
366+
/// `hook` extra.
367+
fn item_has_hook_extra(item: &Item) -> bool {
368+
item.as_table_like()
369+
.and_then(|t| t.get("extras"))
370+
.and_then(Item::as_array)
371+
.map(|a| a.iter().any(|v| v.as_str() == Some("hook")))
372+
.unwrap_or(false)
313373
}
314374

315375
#[cfg(test)]
@@ -322,27 +382,27 @@ mod tests {
322382
fn test_requirements_add() {
323383
let out = requirements_add("requests==2.31.0\n").unwrap().unwrap();
324384
assert!(out.contains("requests==2.31.0"));
325-
assert!(out.contains("socket-patch-hook"));
385+
assert!(out.contains("socket-patch[hook]"));
326386
assert!(out.ends_with('\n'));
327387
}
328388

329389
#[test]
330390
fn test_requirements_add_no_trailing_newline() {
331391
let out = requirements_add("requests").unwrap().unwrap();
332-
assert_eq!(out, "requests\nsocket-patch-hook\n");
392+
assert_eq!(out, "requests\nsocket-patch[hook]\n");
333393
}
334394

335395
#[test]
336396
fn test_requirements_add_idempotent() {
337-
// Both the standalone wheel and the legacy `[hook]` extra are recognized.
397+
// The extra, the standalone wheel, and a pinned variant are all recognized.
398+
assert!(requirements_add("socket-patch[hook]\n").unwrap().is_none());
338399
assert!(requirements_add("socket-patch-hook\n").unwrap().is_none());
339400
assert!(requirements_add("socket-patch-hook==3.3.0\n").unwrap().is_none());
340-
assert!(requirements_add("socket-patch[hook]\n").unwrap().is_none());
341401
}
342402

343403
#[test]
344404
fn test_requirements_remove() {
345-
let out = requirements_remove("requests\nsocket-patch-hook\n")
405+
let out = requirements_remove("requests\nsocket-patch[hook]\n")
346406
.unwrap()
347407
.unwrap();
348408
assert_eq!(out, "requests\n");
@@ -359,7 +419,7 @@ mod tests {
359419
fn test_pep621_add_to_existing_array() {
360420
let toml = "[project]\nname = \"x\"\ndependencies = [\"requests\"]\n";
361421
let out = pyproject_add(toml).unwrap().unwrap();
362-
assert!(out.contains("socket-patch-hook"));
422+
assert!(out.contains("socket-patch[hook]"));
363423
assert!(out.contains("requests"));
364424
// Re-parse to confirm validity + idempotency.
365425
assert!(pyproject_add(&out).unwrap().is_none());
@@ -371,7 +431,7 @@ mod tests {
371431
let out = pyproject_add(toml).unwrap().unwrap();
372432
let doc = out.parse::<DocumentMut>().unwrap();
373433
let deps = doc["project"]["dependencies"].as_array().unwrap();
374-
assert!(deps.iter().any(|v| v.as_str() == Some("socket-patch-hook")));
434+
assert!(deps.iter().any(|v| v.as_str() == Some("socket-patch[hook]")));
375435
}
376436

377437
#[test]
@@ -381,79 +441,74 @@ mod tests {
381441
assert!(out.contains("[build-system]"));
382442
assert!(out.contains("version = \"1.0\""));
383443
assert!(out.contains("requests"));
384-
assert!(out.contains("socket-patch-hook"));
444+
assert!(out.contains("socket-patch[hook]"));
385445
}
386446

387447
#[test]
388448
fn test_pep621_remove() {
389-
let toml = "[project]\ndependencies = [\"requests\", \"socket-patch-hook\"]\n";
449+
let toml = "[project]\ndependencies = [\"requests\", \"socket-patch[hook]\"]\n";
390450
let out = pyproject_remove(toml).unwrap().unwrap();
391-
assert!(!out.contains("socket-patch-hook"));
451+
assert!(!out.contains("socket-patch[hook]"));
392452
assert!(out.contains("requests"));
393453
}
394454

395-
// ── pyproject Poetry (standalone hook key, no extras-merging) ─────
455+
// ── pyproject Poetry (the `socket-patch[hook]` equivalent: the
456+
// `socket-patch` dep carrying the `hook` extra) ─────────────────
396457

397458
#[test]
398-
fn test_poetry_add_new_key() {
459+
fn test_poetry_add_new_dep() {
399460
let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n";
400461
let out = pyproject_add(toml).unwrap().unwrap();
401462
let doc = out.parse::<DocumentMut>().unwrap();
402-
assert_eq!(
403-
doc["tool"]["poetry"]["dependencies"]["socket-patch-hook"].as_str(),
404-
Some("*")
463+
assert!(
464+
item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]),
465+
"poetry dep must carry the hook extra; got:\n{out}"
405466
);
406467
// Idempotent.
407468
assert!(pyproject_add(&out).unwrap().is_none());
408469
}
409470

410471
#[test]
411-
fn test_poetry_leaves_existing_socket_patch_untouched() {
412-
// An existing `socket-patch` dependency must NOT be mutated; we only add
413-
// the standalone `socket-patch-hook` key.
472+
fn test_poetry_merges_extra_into_existing_dep() {
473+
// An existing `socket-patch = "^3.3.0"` gains the hook extra, version kept.
414474
let toml = "[tool.poetry]\nname = \"x\"\n[tool.poetry.dependencies]\nsocket-patch = \"^3.3.0\"\n";
415475
let out = pyproject_add(toml).unwrap().unwrap();
416476
let doc = out.parse::<DocumentMut>().unwrap();
477+
let item = &doc["tool"]["poetry"]["dependencies"]["socket-patch"];
478+
assert!(item_has_hook_extra(item), "hook extra must be added");
417479
assert_eq!(
418-
doc["tool"]["poetry"]["dependencies"]["socket-patch"].as_str(),
480+
item.as_table_like().and_then(|t| t.get("version")).and_then(Item::as_str),
419481
Some("^3.3.0"),
420-
"existing socket-patch dep must be left intact"
421-
);
422-
assert_eq!(
423-
doc["tool"]["poetry"]["dependencies"]["socket-patch-hook"].as_str(),
424-
Some("*")
482+
"existing version must be preserved"
425483
);
426484
}
427485

428486
#[test]
429487
fn test_poetry_subtable_dependency_preserved() {
430-
// A `[tool.poetry.dependencies.socket-patch]` sub-table (version/source)
431-
// must survive untouched; only the standalone hook key is added.
488+
// A `[tool.poetry.dependencies.socket-patch]` sub-table gains the hook
489+
// extra while keeping its version / source.
432490
let toml = "[tool.poetry.dependencies.socket-patch]\nversion = \"^3.3.0\"\ngit = \"https://example.com/x.git\"\n";
433491
let out = pyproject_add(toml).unwrap().unwrap();
434492
let doc = out.parse::<DocumentMut>().unwrap();
435493
let sp = &doc["tool"]["poetry"]["dependencies"]["socket-patch"];
494+
assert!(item_has_hook_extra(sp), "hook extra must be added");
436495
assert_eq!(
437496
sp.as_table_like().and_then(|t| t.get("git")).and_then(Item::as_str),
438497
Some("https://example.com/x.git"),
439498
"sub-table keys must survive"
440499
);
441-
assert_eq!(
442-
doc["tool"]["poetry"]["dependencies"]["socket-patch-hook"].as_str(),
443-
Some("*")
444-
);
445500
// Idempotent.
446501
assert!(pyproject_add(&out).unwrap().is_none());
447502
}
448503

449504
#[test]
450-
fn test_poetry_remove() {
451-
let toml = "[tool.poetry.dependencies]\nsocket-patch-hook = \"*\"\npython = \"^3.9\"\n";
505+
fn test_poetry_remove_strips_extra() {
506+
let toml = "[tool.poetry.dependencies]\nsocket-patch = {version = \"*\", extras = [\"hook\"]}\npython = \"^3.9\"\n";
452507
let out = pyproject_remove(toml).unwrap().unwrap();
453508
let doc = out.parse::<DocumentMut>().unwrap();
454-
assert!(doc["tool"]["poetry"]["dependencies"]
455-
.get("socket-patch-hook")
456-
.is_none());
509+
assert!(!item_has_hook_extra(
510+
&doc["tool"]["poetry"]["dependencies"]["socket-patch"]
511+
));
457512
assert!(doc["tool"]["poetry"]["dependencies"].get("python").is_some());
458513
}
459514

@@ -467,7 +522,7 @@ mod tests {
467522
.as_array()
468523
.unwrap()
469524
.iter()
470-
.any(|v| v.as_str() == Some("socket-patch-hook")));
525+
.any(|v| v.as_str() == Some("socket-patch[hook]")));
471526
}
472527

473528
#[test]
@@ -483,9 +538,8 @@ mod tests {
483538
let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n\n[project.urls]\nHome = \"https://example.com\"\n";
484539
let out = pyproject_add(toml).unwrap().unwrap();
485540
let doc = out.parse::<DocumentMut>().unwrap();
486-
assert_eq!(
487-
doc["tool"]["poetry"]["dependencies"]["socket-patch-hook"].as_str(),
488-
Some("*"),
541+
assert!(
542+
item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]),
489543
"must edit the poetry table, not create [project].dependencies; got:\n{out}"
490544
);
491545
assert!(doc.get("project").and_then(|p| p.get("dependencies")).is_none());
@@ -494,7 +548,7 @@ mod tests {
494548
#[test]
495549
fn test_requirements_preserves_crlf() {
496550
let out = requirements_add("requests\r\n").unwrap().unwrap();
497-
assert_eq!(out, "requests\r\nsocket-patch-hook\r\n");
551+
assert_eq!(out, "requests\r\nsocket-patch[hook]\r\n");
498552
let removed = requirements_remove(&out).unwrap().unwrap();
499553
assert_eq!(removed, "requests\r\n");
500554
}
@@ -508,7 +562,7 @@ mod tests {
508562
let res = add_hook_dependency(&req, ManifestKind::Requirements, false).await;
509563
assert_eq!(res.status, PthStatus::Updated);
510564
let body = tokio::fs::read_to_string(&req).await.unwrap();
511-
assert_eq!(body, "socket-patch-hook\n");
565+
assert_eq!(body, "socket-patch[hook]\n");
512566
}
513567

514568
#[tokio::test]

0 commit comments

Comments
 (0)