diff --git a/Cargo.lock b/Cargo.lock index 23adebe..523c0e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "async-channel" version = "2.5.0" @@ -89,10 +95,10 @@ checksum = "49330107ffb66056ccb16b57549bfd145d696c85f3f0ad7fa1b2329c1c421a1a" dependencies = [ "cfg-if", "flate2", - "getrandom", + "getrandom 0.3.3", "hex", "hexane", - "itertools", + "itertools 0.14.0", "leb128", "rand", "rustc-hash", @@ -105,6 +111,29 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "aws-lc-rs" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5932a7d9d28b0d2ea34c6b3779d35e3dd6f6345317c34e73438c4f1f29144151" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1826f2e4cfc2cd19ee53c42fbf68e2f81ec21108e0b7ecf6a71cf062137360fc" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.4" @@ -189,6 +218,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "2.9.1" @@ -250,9 +299,20 @@ version = "1.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" dependencies = [ + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-expr" version = "0.20.1" @@ -286,7 +346,27 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", ] [[package]] @@ -304,6 +384,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -410,6 +500,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -494,6 +590,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -593,6 +695,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -685,6 +798,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gobject-sys" version = "0.20.10" @@ -970,6 +1089,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "io-uring" version = "0.7.8" @@ -997,6 +1125,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1012,6 +1149,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1040,6 +1187,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -1079,6 +1236,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1105,6 +1271,12 @@ dependencies = [ "syn", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1125,6 +1297,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1159,6 +1341,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "overload" version = "0.1.1" @@ -1195,6 +1383,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "potential_utf" version = "0.1.2" @@ -1213,6 +1407,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -1231,6 +1435,83 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "anyhow", + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b83dc42f9d41f50d38180dad65f0c99763b65a3ff2a81bf351dd35a1df8bf" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.40" @@ -1272,7 +1553,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.3", ] [[package]] @@ -1373,6 +1654,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1398,6 +1693,54 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1458,6 +1801,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "samod-py" +version = "0.5.0" +dependencies = [ + "anyhow", + "automerge", + "futures", + "pyo3", + "pyo3-async-runtimes", + "rustls", + "samod", + "samod-core", + "serde_json", + "tokio", + "tokio-tungstenite 0.27.0", + "tracing", +] + [[package]] name = "samod-test-harness" version = "0.1.0" @@ -1468,6 +1829,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.219" @@ -1626,6 +2019,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.104" @@ -1683,7 +2082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.59.0", @@ -1773,6 +2172,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -1817,7 +2226,11 @@ checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.27.0", ] @@ -2011,6 +2424,8 @@ dependencies = [ "httparse", "log", "rand", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -2034,6 +2449,18 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" @@ -2063,7 +2490,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ - "getrandom", + "getrandom 0.3.3", "js-sys", "serde", "wasm-bindgen", @@ -2222,7 +2649,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -2255,13 +2682,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2270,7 +2703,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2291,6 +2724,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2444,6 +2886,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 1decd79..2e71aa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] edition = "2024" resolver = "3" -members = [ "samod","samod-core", "samod-test-harness"] +members = [ "samod","samod-core", "samod-test-harness", "samod-py"] diff --git a/samod-core/src/automerge_url.rs b/samod-core/src/automerge_url.rs index 5195048..453d53c 100644 --- a/samod-core/src/automerge_url.rs +++ b/samod-core/src/automerge_url.rs @@ -10,6 +10,12 @@ pub struct AutomergeUrl { path: Option>, } +impl AutomergeUrl { + pub fn document_id(&self) -> &DocumentId { + &self.document_id + } +} + impl From<&DocumentId> for AutomergeUrl { fn from(id: &DocumentId) -> Self { AutomergeUrl { diff --git a/samod-py/Cargo.toml b/samod-py/Cargo.toml new file mode 100644 index 0000000..f412198 --- /dev/null +++ b/samod-py/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "samod-py" +version = "0.5.0" +edition = "2024" +authors = ["Alex Good ", "Kyle Kelley "] +description = "Python bindings for samod" +license = "MIT" +repository = "https://github.com/alexjg/samod" + +[lib] +name = "samod_py" +crate-type = ["cdylib"] + +[dependencies] +samod = { path = "../samod", features = ["tokio", "tungstenite"] } +samod-core = { path = "../samod-core" } +automerge = "0.7.0" +pyo3 = { version = "0.24", features = ["extension-module", "abi3-py38", "anyhow"] } +pyo3-async-runtimes = { version = "0.24", features = ["tokio-runtime"] } +tokio = { version = "1.46.0", features = ["rt-multi-thread", "macros", "time"] } +tokio-tungstenite = { version = "0.27.0", features = ["rustls-tls-native-roots"] } +rustls = { version = "0.23", features = ["aws-lc-rs"] } +futures = "0.3.31" +anyhow = "1.0" +tracing = "0.1.41" +serde_json = "1.0" diff --git a/samod-py/README.md b/samod-py/README.md new file mode 100644 index 0000000..76b3236 --- /dev/null +++ b/samod-py/README.md @@ -0,0 +1,99 @@ +# samod + +Python bindings for Automerge via [samod](https://github.com/alexjg/samod). + +## Development + +```bash +pip install maturin +cd samod-py +maturin develop --release +``` + +## Usage + +```python +import asyncio +import samod + +async def main(): + repo = samod.Repo() + print(f"Peer ID: {repo.peer_id()}") + + # Connect to sync server + await repo.connect_websocket("ws://localhost:3030") + + # Create a document + doc = await repo.create() + await doc.set_string("title", "Hello World") + + # Read it back + title = await doc.get_string("title") + print(f"Title: {title}") + + url = await doc.url() + print(f"Automerge URL: {url}") + + # Find an existing document + existing = await repo.find("automerge:...") + if existing: + keys = await existing.get_keys() + print(f"Keys: {keys}") + + await repo.stop() + +asyncio.run(main()) +``` + +## API + +### `Repo` + +Repository managing documents, storage, and sync. + +**Methods:** + +- `Repo()` - Create repo with in-memory storage +- `peer_id() -> str` - Get this peer's ID +- `async connect_websocket(url: str)` - Connect to WebSocket sync server +- `async when_connected(peer_id: str)` - Block until connected to specific peer +- `async find(doc_id: str) -> Optional[DocHandle]` - Find document by AutomergeUrl +- `async create() -> DocHandle` - Create new document +- `async stop()` - Stop repo and abort background connections + +**Connection handling:** + +`connect_websocket()` spawns connections in the background. Use `when_connected(peer_id)` to wait for a specific peer, or `asyncio.sleep()` as a workaround when the peer ID is unknown. + +### `DocHandle` + +Handle to an Automerge document. + +**Methods:** + +- `async document_id() -> str` - Get document ID (AutomergeUrl) +- `async dump() -> bytes` - Serialize document to bytes +- `async get_keys() -> List[str]` - List all root-level keys +- `async get_string(key: str) -> Optional[str]` - Get string field +- `async set_string(key: str, value: str)` - Set string field + +## Development + +```bash +# Development build +maturin develop + +# Release build +maturin develop --release + +# Build wheel +maturin build --release +``` + +## Architecture Notes + +This package relies on PyO3 and `pyo3-async-runtimes` to bind the core automerge/samod Rust code in Python with native async await support. + +## License + +MIT diff --git a/samod-py/pyproject.toml b/samod-py/pyproject.toml new file mode 100644 index 0000000..8053abb --- /dev/null +++ b/samod-py/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "samod" +version = "0.5.0a3" +description = "Python bindings for samod - Rust implementation of automerge-repo" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [ + { name = "Alex Good", email = "alex@patternist.xyz" }, + { name = "Kyle Kelley", email = "rgbkrk@gmail.com" }, +] +keywords = ["automerge", "crdt", "sync", "collaboration", "distributed"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/alexjg/samod" +Repository = "https://github.com/alexjg/samod" +Documentation = "https://github.com/alexjg/samod/tree/main/samod-py" + +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "samod" diff --git a/samod-py/src/lib.rs b/samod-py/src/lib.rs new file mode 100644 index 0000000..b7eff9a --- /dev/null +++ b/samod-py/src/lib.rs @@ -0,0 +1,420 @@ +use pyo3::prelude::*; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3_async_runtimes::tokio::future_into_py; +use samod::DocumentId; +use std::sync::Arc; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::JoinHandle; + +use automerge::{transaction::Transactable, ReadDoc}; + +type TaskSet = Arc>>>; + +/// A repository for managing Automerge documents with sync capabilities. +/// +/// A Repo is similar to a database - it manages documents, storage, and networking. +/// Documents are CRDTs (Conflict-Free Replicated Data Types) that automatically +/// merge concurrent changes from multiple users. +/// +/// This repo uses in-memory storage and has no network adapters by default. +/// +/// Examples: +/// >>> repo = Repo() +/// >>> doc = await repo.create() +/// >>> await doc.set_string("title", "My Document") +#[pyclass] +struct Repo { + inner: Arc, + _runtime: Arc, + tasks: TaskSet, +} + +#[pymethods] +impl Repo { + #[new] + fn new() -> PyResult { + // Create a new tokio runtime in a separate thread + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; + + let repo = runtime.block_on(async { + samod::Repo::build_tokio() + .with_storage(samod::storage::InMemoryStorage::new()) + .load() + .await + }); + + Ok(Repo { + inner: Arc::new(repo), + _runtime: Arc::new(runtime), + tasks: Arc::new(AsyncMutex::new(Vec::new())), + }) + } + + /// Get this repository's unique peer ID. + /// + /// The peer ID identifies this repo instance in sync operations. + /// Each repo has a unique ID that persists across the lifetime of the Repo. + /// + /// Returns: + /// str: A unique identifier for this peer + fn peer_id(&self) -> String { + self.inner.peer_id().to_string() + } + + fn __repr__(&self) -> String { + format!("Repo(peer_id='{}')", self.peer_id()) + } + + /// Connect to a WebSocket sync server for real-time collaboration. + /// + /// Establishes a WebSocket connection to sync documents with remote peers. + /// Changes made locally will be sent to the server, and changes from other + /// peers will be received and merged automatically. + /// + /// The connection runs in the background after this coroutine completes. + /// + /// Args: + /// url (str): WebSocket URL (e.g., "ws://localhost:3030" or "wss://sync.automerge.org") + /// + /// Returns: + /// Coroutine: Resolves when the connection is established + /// + /// Raises: + /// ValueError: If the URL is invalid + /// RuntimeError: If the connection fails + fn connect_websocket<'py>( + &self, + py: Python<'py>, + url: String, + ) -> PyResult> { + let repo = self.inner.clone(); + let tasks = self.tasks.clone(); + + future_into_py(py, async move { + // Parse the URL + let url = url.parse::() + .map_err(|e| PyValueError::new_err(format!("Invalid URL: {}", e)))?; + + // Connect to the WebSocket + let (ws_stream, _) = tokio_tungstenite::connect_async(url) + .await + .map_err(|e| PyRuntimeError::new_err(format!("Failed to connect: {}", e)))?; + + // Spawn the connection in the background + let handle = tokio::spawn(async move { + let reason = repo.connect_tungstenite(ws_stream, samod::ConnDirection::Outgoing).await; + tracing::info!("Connection finished: {:?}", reason); + }); + + tasks.lock().await.push(handle); + + Ok(None::>) + }) + } + + /// Find a document by its ID (AutomergeUrl format) + fn find<'py>( + &self, + py: Python<'py>, + doc_id: String, + ) -> PyResult> { + let repo = self.inner.clone(); + + // Parse the AutomergeUrl + let url: samod_core::AutomergeUrl = doc_id.parse() + .map_err(|e| PyValueError::new_err(format!("Invalid document ID: {}", e)))?; + + let document_id = url.document_id().clone(); + + future_into_py(py, async move { + let result = repo.find(document_id).await; + + match result { + Ok(Some(handle)) => { + let document_id = handle.document_id().clone(); + Ok(Some(DocHandle { + document_id, + inner: Arc::new(AsyncMutex::new(handle)), + })) + } + Ok(None) => Ok(None), + Err(_) => Err(PyRuntimeError::new_err("Repository stopped")), + } + }) + } + + /// Create a new empty Automerge document. + /// + /// The document is automatically saved and announced to connected peers. + /// You can share the document with others using `handle.url()`. + /// + /// Returns: + /// Coroutine[DocHandle]: A handle to the newly created document + /// + /// Raises: + /// RuntimeError: If the repository has stopped + fn create<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + let repo = self.inner.clone(); + + future_into_py(py, async move { + let initial_doc = automerge::Automerge::new(); + let result = repo.create(initial_doc).await; + + match result { + Ok(handle) => { + let document_id = handle.document_id().clone(); + Ok(DocHandle { + inner: Arc::new(AsyncMutex::new(handle)), + document_id, + }) + } + Err(_) => Err(PyRuntimeError::new_err("Repository stopped")), + } + }) + } + + /// Wait until this repo connects to a specific peer. + /// + /// Blocks until a connection is established with the peer identified + /// by the given peer ID. Useful for ensuring sync readiness before + /// performing operations that depend on a specific peer. + /// + /// Args: + /// peer_id (str): The peer ID to wait for + /// + /// Returns: + /// Coroutine: Resolves when connected to the peer + /// + /// Raises: + /// RuntimeError: If the repository has stopped + fn when_connected<'py>( + &self, + py: Python<'py>, + peer_id: String, + ) -> PyResult> { + let repo = self.inner.clone(); + let peer_id: samod_core::PeerId = peer_id.into(); + + future_into_py(py, async move { + repo.when_connected(peer_id).await + .map_err(|_| PyRuntimeError::new_err("Repository stopped"))?; + Ok(None::>) + }) + } + + /// Stop the repository and close all document connections + fn stop<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + let repo = self.inner.clone(); + let tasks = self.tasks.clone(); + + future_into_py(py, async move { + for handle in tasks.lock().await.drain(..) { + handle.abort(); + } + repo.stop().await; + Ok(None::>) + }) + } +} + +/// A handle to an Automerge document in the repository. +/// +/// DocHandles provide access to CRDT documents that support concurrent editing. +/// Multiple users can make changes simultaneously, and Automerge will automatically +/// merge those changes. +#[pyclass] +struct DocHandle { + inner: Arc>, + document_id: DocumentId, +} + +#[pymethods] +impl DocHandle { + /// Get the unique document ID. + /// + /// Returns: + /// str: The document's ID + fn document_id(&self) -> String { + self.document_id.to_string() + } + + /// Get the unique document URL. + /// + /// Returns the AutomergeUrl that identifies this document. + /// This URL can be shared with others to give them access to the document. + /// + /// Returns: + /// str: The document's URL (e.g., "automerge:...") + fn url(&self) -> String { + format!("automerge:{}", self.document_id) + } + + fn __repr__(&self) -> String { + format!("DocHandle(url='{}')", self.url()) + } + + /// Retrieve the document as a byte array. + /// + /// Returns a compact binary representation of the entire document, + /// including its complete edit history. + /// + /// Returns: + /// Coroutine[bytes]: The serialized document + /// + /// Raises: + /// RuntimeError: If serialization fails + fn dump<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + let handle = self.inner.clone(); + + future_into_py(py, async move { + let handle = handle.lock().await; + let bytes = handle.with_document(|doc| { + Ok::<_, automerge::AutomergeError>(doc.save()) + }); + + match bytes { + Ok(b) => Ok(b), + Err(e) => Err(PyRuntimeError::new_err(format!("Failed to save document: {}", e))), + } + }) + } + + /// Set a string field in the document root. + /// + /// In Automerge, strings are collaborative text sequences by default. + /// Concurrent updates from different users will be merged as intelligently as possible. + /// + /// Args: + /// key (str): The field name to set + /// value (str): The string value to set + /// + /// Returns: + /// Coroutine: Resolves when the operation completes and is saved/synced + /// + /// Raises: + /// RuntimeError: If the operation fails + fn set_string<'py>( + &self, + py: Python<'py>, + key: String, + value: String, + ) -> PyResult> { + let handle = self.inner.clone(); + + future_into_py(py, async move { + let handle = handle.lock().await; + + handle.with_document(|doc| { + doc.transact(|tx| { + tx.put(automerge::ROOT, key, value)?; + Ok::<_, automerge::AutomergeError>(()) + }).map_err(|e| e.error)?; + Ok::<_, automerge::AutomergeError>(()) + }) + .map_err(|e| PyRuntimeError::new_err(format!("Document operation failed: {:?}", e)))?; + + Ok(None::>) + }) + } + + /// Get a string field from the document root. + /// + /// Reads the current value of a string field. If the field doesn't exist + /// or is not a string, returns None. + /// + /// Args: + /// key (str): The field name to retrieve + /// + /// Returns: + /// Coroutine[Optional[str]]: The string value if it exists, None otherwise + /// + /// Raises: + /// RuntimeError: If reading fails + fn get_string<'py>( + &self, + py: Python<'py>, + key: String, + ) -> PyResult> { + let handle = self.inner.clone(); + + future_into_py(py, async move { + let handle = handle.lock().await; + + let result = handle.with_document(|doc| { + match doc.get(automerge::ROOT, &key) { + Ok(Some((automerge::Value::Scalar(s), _))) => { + match s.as_ref() { + automerge::ScalarValue::Str(string) => Ok::<_, automerge::AutomergeError>(Some(string.to_string())), + _ => Ok(None), + } + } + Ok(Some(_)) => Ok(None), + Ok(None) => Ok(None), + Err(e) => Err(e), + } + }) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to get field: {}", e)))?; + + Ok(result) + }) + } + + /// Get all keys at the document root. + /// + /// Returns a list of all field names currently in the document. + /// + /// Returns: + /// Coroutine[List[str]]: List of all keys in the document + /// + /// Raises: + /// RuntimeError: If the operation fails + fn get_keys<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + let handle = self.inner.clone(); + + future_into_py(py, async move { + let handle = handle.lock().await; + + let keys = handle.with_document(|doc| { + let mut keys = Vec::new(); + for key in doc.keys(automerge::ROOT) { + keys.push(key.to_string()); + } + Ok::<_, automerge::AutomergeError>(keys) + }) + .map_err(|e| PyRuntimeError::new_err(format!("Failed to get keys: {}", e)))?; + + Ok(keys) + }) + } +} + +/// Samod +/// +/// Library for building local-first collaborative applications using +/// Automerge CRDTs (Conflict-Free Replicated Data Types). +/// +/// Key concepts: +/// - **Repo**: Manages documents, storage, and networking +/// - **DocHandle**: A handle to a specific document for reading/writing +/// - **AutomergeUrl**: Unique identifier for documents (automerge:...) +/// +/// See also: https://automerge.org/docs/ +#[pymodule(name = "samod")] +fn samod_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +}