From 7e8a46f9b823c0e36d7b763d9549f8e22f064534 Mon Sep 17 00:00:00 2001 From: cschen Date: Thu, 8 Aug 2024 23:58:45 +0200 Subject: [PATCH] feat(python): new leaner glue, up to date --- src/api/change.rs | 22 ++- src/client.rs | 47 +++--- src/ffi/python/client.rs | 53 +++++++ src/ffi/python/controllers.rs | 161 ++++++++++++++++++++ src/ffi/python/mod.rs | 101 ++++++++++++ src/ffi/python/workspace.rs | 116 ++++++++++++++ src/ffi/{python.rs => python_monolithic.rs} | 7 +- 7 files changed, 476 insertions(+), 31 deletions(-) create mode 100644 src/ffi/python/client.rs create mode 100644 src/ffi/python/controllers.rs create mode 100644 src/ffi/python/mod.rs create mode 100644 src/ffi/python/workspace.rs rename src/ffi/{python.rs => python_monolithic.rs} (98%) diff --git a/src/api/change.rs b/src/api/change.rs index a5eee55..9754f8a 100644 --- a/src/api/change.rs +++ b/src/api/change.rs @@ -32,8 +32,9 @@ pub struct Op(pub(crate) woot::crdt::Op); /// #[derive(Clone, Debug, Default)] -#[cfg_attr(feature = "python", pyo3::pyclass)] #[cfg_attr(feature = "js", napi_derive::napi(object))] +#[cfg_attr(feature = "python", pyo3::pyclass)] +#[cfg_attr(feature = "python", pyo3(get_all))] pub struct TextChange { /// range start of text change, as char indexes in buffer previous state pub start: u32, @@ -76,11 +77,11 @@ impl TextChange { } pub fn span(&self) -> std::ops::Range { - self.start as usize .. self.end as usize + self.start as usize..self.end as usize } /// consume the [TextChange], transforming it into a Vec of [Op] - pub fn transform(self, woot: &Woot) -> WootResult> { + pub fn transform(&self, woot: &Woot) -> WootResult> { let mut out = Vec::new(); if self.is_empty() { return Ok(out); @@ -180,7 +181,8 @@ mod tests { #[test] fn textchange_apply_works_for_insertions() { let change = super::TextChange { - start: 5, end: 5, + start: 5, + end: 5, content: " cruel".to_string(), }; let result = change.apply("hello world!"); @@ -190,7 +192,8 @@ mod tests { #[test] fn textchange_apply_works_for_deletions() { let change = super::TextChange { - start: 5, end: 11, + start: 5, + end: 11, content: "".to_string(), }; let result = change.apply("hello cruel world!"); @@ -200,7 +203,8 @@ mod tests { #[test] fn textchange_apply_works_for_replacements() { let change = super::TextChange { - start: 5, end: 11, + start: 5, + end: 11, content: " not very pleasant".to_string(), }; let result = change.apply("hello cruel world!"); @@ -210,7 +214,8 @@ mod tests { #[test] fn textchange_apply_never_panics() { let change = super::TextChange { - start: 100, end: 110, + start: 100, + end: 110, content: "a very long string \n which totally matters".to_string(), }; let result = change.apply("a short text"); @@ -229,7 +234,8 @@ mod tests { #[test] fn empty_textchange_doesnt_alter_buffer() { let change = super::TextChange { - start: 42, end: 42, + start: 42, + end: 42, content: "".to_string(), }; let result = change.apply("some important text"); diff --git a/src/client.rs b/src/client.rs index 98c1f0c..b7952c3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,9 +8,12 @@ use dashmap::DashMap; use tonic::transport::{Channel, Endpoint}; use uuid::Uuid; +use crate::workspace::Workspace; use codemp_proto::auth::auth_client::AuthClient; use codemp_proto::auth::{Token, WorkspaceJoinRequest}; -use crate::workspace::Workspace; + +#[cfg(feature = "python")] +use pyo3::prelude::*; #[derive(Debug)] pub struct AuthWrap { @@ -21,9 +24,7 @@ pub struct AuthWrap { impl AuthWrap { async fn try_new(username: &str, password: &str, host: &str) -> crate::Result { - let channel = Endpoint::from_shared(host.to_string())? - .connect() - .await?; + let channel = Endpoint::from_shared(host.to_string())?.connect().await?; Ok(AuthWrap { username: username.to_string(), @@ -33,16 +34,16 @@ impl AuthWrap { } async fn login_workspace(&self, ws: &str) -> crate::Result { - Ok( - self.service.clone() - .login(WorkspaceJoinRequest { - username: self.username.clone(), - password: self.password.clone(), - workspace_id: Some(ws.to_string()) - }) - .await? - .into_inner() - ) + Ok(self + .service + .clone() + .login(WorkspaceJoinRequest { + username: self.username.clone(), + password: self.password.clone(), + workspace_id: Some(ws.to_string()), + }) + .await? + .into_inner()) } } @@ -53,6 +54,7 @@ impl AuthWrap { /// can be used to interact with server #[derive(Debug, Clone)] #[cfg_attr(feature = "js", napi_derive::napi)] +#[cfg_attr(feature = "python", pyclass)] pub struct Client(Arc); #[derive(Debug)] @@ -68,7 +70,7 @@ impl Client { pub async fn new( host: impl AsRef, username: impl AsRef, - password: impl AsRef + password: impl AsRef, ) -> crate::Result { let host = if host.as_ref().starts_with("http") { host.as_ref().to_string() @@ -95,10 +97,13 @@ impl Client { workspace.as_ref().to_string(), self.0.user_id, &self.0.host, - token.clone() - ).await?; + token.clone(), + ) + .await?; - self.0.workspaces.insert(workspace.as_ref().to_string(), ws.clone()); + self.0 + .workspaces + .insert(workspace.as_ref().to_string(), ws.clone()); Ok(ws) } @@ -115,7 +120,11 @@ impl Client { /// get name of all active [Workspace]s pub fn active_workspaces(&self) -> Vec { - self.0.workspaces.iter().map(|x| x.key().to_string()).collect() + self.0 + .workspaces + .iter() + .map(|x| x.key().to_string()) + .collect() } /// accessor for user id diff --git a/src/ffi/python/client.rs b/src/ffi/python/client.rs new file mode 100644 index 0000000..d3a7de1 --- /dev/null +++ b/src/ffi/python/client.rs @@ -0,0 +1,53 @@ +use crate::workspace::Workspace; +use crate::Client; +use pyo3::prelude::*; +use pyo3::types::{PyBool, PyList, PyString}; + +// #[pyfunction] +// pub fn codemp_init<'a>(py: Python<'a>) -> PyResult> { +// Ok(Py::new(py, Client::default())?) +// } + +#[pymethods] +impl Client { + #[new] + fn pyconnect(host: String, username: String, password: String) -> PyResult { + let cli = + pyo3_asyncio::tokio::get_runtime().block_on(Client::new(host, username, password)); + Ok(cli?) + } + + #[pyo3(name = "join_workspace")] + fn pyjoin_workspace<'a>(&'a self, py: Python<'a>, workspace: String) -> PyResult<&PyAny> { + let rc = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let workspace: Workspace = rc.join_workspace(workspace.as_str()).await?; + Python::with_gil(|py| Py::new(py, workspace)) + }) + } + + #[pyo3(name = "leave_workspace")] + fn pyleave_workspace<'p>(&'p self, py: Python<'p>, id: String) -> &PyBool { + PyBool::new(py, self.leave_workspace(id.as_str())) + } + + // join a workspace + #[pyo3(name = "get_workspace")] + fn pyget_workspace(&self, py: Python<'_>, id: String) -> PyResult>> { + match self.get_workspace(id.as_str()) { + Some(ws) => Ok(Some(Py::new(py, ws)?)), + None => Ok(None), + } + } + + #[pyo3(name = "active_workspaces")] + fn pyactive_workspaces<'p>(&'p self, py: Python<'p>) -> &PyList { + PyList::new(py, self.active_workspaces()) + } + + #[pyo3(name = "user_id")] + fn pyuser_id<'p>(&'p self, py: Python<'p>) -> &PyString { + PyString::new(py, self.user_id().to_string().as_str()) + } +} diff --git a/src/ffi/python/controllers.rs b/src/ffi/python/controllers.rs new file mode 100644 index 0000000..e7b8537 --- /dev/null +++ b/src/ffi/python/controllers.rs @@ -0,0 +1,161 @@ +use crate::api::Controller; +use crate::api::Cursor; +use crate::api::TextChange; +use crate::buffer::Controller as BufferController; +use crate::cursor::Controller as CursorController; +use pyo3::prelude::*; +use pyo3::types::PyType; + +// use super::CodempController; + +#[pymethods] +impl CursorController { + #[pyo3(name = "send")] + pub fn pysend(&self, path: String, start: (i32, i32), end: (i32, i32)) -> PyResult<()> { + let pos = Cursor { + start, + end, + buffer: path, + user: None, + }; + + Ok(self.send(pos)?) + } + + #[pyo3(name = "try_recv")] + pub fn pytry_recv(&self, py: Python<'_>) -> PyResult>> { + match self.try_recv()? { + Some(cur_event) => Ok(Some(Py::new(py, cur_event)?)), + None => Ok(None), + } + } + + #[pyo3(name = "recv")] + pub fn pyrecv<'p>(&'p self, py: Python<'p>) -> PyResult<&'p PyAny> { + let rc = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let cur_event: Cursor = rc.recv().await?; + Python::with_gil(|py| Py::new(py, cur_event)) + }) + } + + #[pyo3(name = "poll")] + pub fn pypoll<'p>(&'p self, py: Python<'p>) -> PyResult<&'p PyAny> { + let rc = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { Ok(rc.poll().await?) }) + } + + #[pyo3(name = "stop")] + pub fn pystop(&self) -> bool { + self.stop() + } +} + +#[pymethods] +impl Cursor { + #[getter(start)] + fn pystart(&self) -> (i32, i32) { + self.start + } + + #[getter(end)] + fn pyend(&self) -> (i32, i32) { + self.end + } + + #[getter(buffer)] + fn pybuffer(&self) -> String { + self.buffer.clone() + } + + #[getter(user)] + fn pyuser(&self) -> String { + match self.user { + Some(user) => user.to_string(), + None => "".to_string(), + } + } +} + +#[pymethods] +impl BufferController { + #[pyo3(name = "content")] + fn pycontent(&self) -> String { + self.content().clone() + } + + #[pyo3(name = "send")] + fn pysend(&self, start: u32, end: u32, txt: String) -> PyResult<()> { + let op = TextChange { + start, + end, + content: txt, + }; + Ok(self.send(op)?) + } + + #[pyo3(name = "try_recv")] + fn pytry_recv(&self, py: Python<'_>) -> PyResult { + match self.try_recv()? { + Some(txt_change) => { + let evt = txt_change; + Ok(evt.into_py(py)) + } + None => Ok(py.None()), + } + } + + #[pyo3(name = "recv")] + fn pyrecv<'p>(&'p self, py: Python<'p>) -> PyResult<&'p PyAny> { + let rc = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let txt_change: TextChange = rc.recv().await?; + Python::with_gil(|py| Py::new(py, txt_change)) + }) + } + + #[pyo3(name = "poll")] + fn pypoll<'p>(&'p self, py: Python<'p>) -> PyResult<&'p PyAny> { + let rc = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { Ok(rc.poll().await?) }) + } +} + +#[pymethods] +impl TextChange { + #[pyo3(name = "is_deletion")] + fn pyis_deletion(&self) -> bool { + self.is_deletion() + } + + #[pyo3(name = "is_addition")] + fn pyis_addition(&self) -> bool { + self.is_addition() + } + + #[pyo3(name = "is_empty")] + fn pyis_empty(&self) -> bool { + self.is_empty() + } + + #[pyo3(name = "apply")] + fn pyapply(&self, txt: &str) -> String { + self.apply(txt) + } + + #[classmethod] + #[pyo3(name = "from_diff")] + fn pyfrom_diff(_cls: &PyType, before: &str, after: &str) -> TextChange { + TextChange::from_diff(before, after) + } + + #[classmethod] + #[pyo3(name = "index_to_rowcol")] + fn pyindex_to_rowcol(_cls: &PyType, txt: &str, index: usize) -> (i32, i32) { + TextChange::index_to_rowcol(txt, index).into() + } +} diff --git a/src/ffi/python/mod.rs b/src/ffi/python/mod.rs new file mode 100644 index 0000000..305a86a --- /dev/null +++ b/src/ffi/python/mod.rs @@ -0,0 +1,101 @@ +pub mod client; +pub mod controllers; +pub mod workspace; + +use std::sync::Arc; + +use crate::{ + api::Cursor, api::TextChange, buffer::Controller as BufferController, + cursor::Controller as CursorController, Client, Workspace, +}; +use pyo3::exceptions::{PyConnectionError, PyRuntimeError, PySystemError}; +use pyo3::prelude::*; +use tokio::sync::{mpsc, Mutex}; + +impl From for PyErr { + fn from(value: crate::Error) -> Self { + match value { + crate::Error::Transport { status, message } => { + PyConnectionError::new_err(format!("Transport error: ({}) {}", status, message)) + } + crate::Error::Channel { send } => { + PyConnectionError::new_err(format!("Channel error (send:{})", send)) + } + crate::Error::InvalidState { msg } => { + PyRuntimeError::new_err(format!("Invalid state: {}", msg)) + } + crate::Error::Deadlocked => PyRuntimeError::new_err("Deadlock, retry."), + } + } +} + +#[derive(Debug, Clone)] +struct LoggerProducer(mpsc::Sender); + +impl std::io::Write for LoggerProducer { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let _ = self.0.try_send(String::from_utf8_lossy(buf).to_string()); // ignore: logger disconnected or with full buffer + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[pyclass] +struct PyLogger(Arc>>); + +#[pymethods] +impl PyLogger { + #[new] + fn init_logger(debug: bool) -> PyResult { + let (tx, rx) = mpsc::channel(256); + let level = if debug { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }; + + let format = tracing_subscriber::fmt::format() + .without_time() + .with_level(true) + .with_target(true) + .with_thread_ids(false) + .with_thread_names(false) + .with_file(false) + .with_line_number(false) + .with_source_location(false) + .compact(); + + match tracing_subscriber::fmt() + .with_ansi(false) + .event_format(format) + .with_max_level(level) + .with_writer(std::sync::Mutex::new(LoggerProducer(tx))) + .try_init() + { + Ok(_) => Ok(PyLogger(Arc::new(Mutex::new(rx)))), + Err(_) => Err(PySystemError::new_err("A logger already exists")), + } + } + + fn listen<'p>(&'p self, py: Python<'p>) -> PyResult<&'p PyAny> { + let rc = self.0.clone(); + pyo3_asyncio::tokio::future_into_py(py, async move { Ok(rc.lock().await.recv().await) }) + } +} + +#[pymodule] +fn codemp(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} diff --git a/src/ffi/python/workspace.rs b/src/ffi/python/workspace.rs new file mode 100644 index 0000000..5356c0b --- /dev/null +++ b/src/ffi/python/workspace.rs @@ -0,0 +1,116 @@ +use crate::buffer::Controller as CodempBufferController; +use crate::cursor::Controller as CodempCursorController; +use crate::workspace::Workspace as CodempWorkspace; +use pyo3::prelude::*; +use pyo3::types::PyString; + +#[pymethods] +impl CodempWorkspace { + // join a workspace + #[pyo3(name = "create")] + fn pycreate<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<&'p PyAny> { + let ws = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + ws.create(path.as_str()).await?; + Ok(()) + }) + } + #[pyo3(name = "attach")] + fn pyattach<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<&PyAny> { + let ws = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let buffctl: CodempBufferController = ws.attach(path.as_str()).await?; + Python::with_gil(|py| Py::new(py, buffctl)) + }) + } + + #[pyo3(name = "detach")] + fn pydetach(&self, path: String) -> bool { + match self.detach(path.as_str()) { + crate::workspace::DetachResult::NotAttached => false, + crate::workspace::DetachResult::Detaching => true, + crate::workspace::DetachResult::AlreadyDetached => true, + } + } + + #[pyo3(name = "fetch_buffers")] + fn pyfetch_buffers<'p>(&'p self, py: Python<'p>) -> PyResult<&PyAny> { + let ws = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + ws.fetch_buffers().await?; + Ok(()) + }) + } + + #[pyo3(name = "fetch_users")] + fn pyfetch_users<'p>(&'p self, py: Python<'p>) -> PyResult<&PyAny> { + let ws = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + ws.fetch_users().await?; + Ok(()) + }) + } + + #[pyo3(name = "list_buffer_users")] + fn pylist_buffer_users<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<&PyAny> { + let ws = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let usrlist: Vec = ws + .list_buffer_users(path.as_str()) + .await? + .into_iter() + .map(|e| e.id) + .collect(); + + Ok(usrlist) + }) + } + + #[pyo3(name = "delete")] + fn pydelete<'p>(&'p self, py: Python<'p>, path: String) -> PyResult<&PyAny> { + let ws = self.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + ws.delete(path.as_str()).await?; + Ok(()) + }) + } + + #[pyo3(name = "id")] + fn pyid(&self, py: Python<'_>) -> Py { + PyString::new(py, self.id().as_str()).into() + } + + #[pyo3(name = "cursor")] + fn pycursor(&self, py: Python<'_>) -> PyResult> { + Py::new(py, self.cursor()) + } + + #[pyo3(name = "buffer_by_name")] + fn pybuffer_by_name( + &self, + py: Python<'_>, + path: String, + ) -> PyResult>> { + let Some(bufctl) = self.buffer_by_name(path.as_str()) else { + return Ok(None); + }; + + Ok(Some(Py::new(py, bufctl)?)) + } + + #[pyo3(name = "buffer_list")] + fn pybuffer_list(&self) -> Vec { + self.buffer_list() + } + + #[pyo3(name = "filetree")] + fn pyfiletree(&self) -> Vec { + self.filetree() + } +} diff --git a/src/ffi/python.rs b/src/ffi/python_monolithic.rs similarity index 98% rename from src/ffi/python.rs rename to src/ffi/python_monolithic.rs index 6fde23c..cb50e6d 100644 --- a/src/ffi/python.rs +++ b/src/ffi/python_monolithic.rs @@ -1,4 +1,3 @@ -use pyo3::types::{PyList, PyTuple}; use std::{format, sync::Arc}; use tokio::sync::{mpsc, Mutex, RwLock}; use tracing; @@ -7,9 +6,9 @@ use tracing_subscriber; use crate::prelude::*; use pyo3::{ - exceptions::{PyBaseException, PyConnectionError, PyRuntimeError}, + exceptions::{PyConnectionError, PyRuntimeError}, prelude::*, - types::{PyString, PyType}, + types::{PyList, PyString, PyTuple, PyType}, }; // ERRORS And LOGGING ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -25,7 +24,7 @@ impl From for PyErr { CodempError::InvalidState { msg } => { PyRuntimeError::new_err(format!("Invalid state: {}", msg)) } - CodempError::Deadlocked => PyRuntimeError::new_err(format!("Deadlock, retry.")) + CodempError::Deadlocked => PyRuntimeError::new_err(format!("Deadlock, retry.")), } } }