initial commit

Former-commit-id: fbb58155042cd05b869941cdbdd83819b7c1907e
This commit is contained in:
Camillo Schenone 2023-08-17 18:39:47 +02:00
commit 1ab1ac36be
14 changed files with 847 additions and 0 deletions

120
.github/workflows/CI.yml vendored Normal file
View file

@ -0,0 +1,120 @@
# This file is autogenerated by maturin v1.1.0
# To update, run
#
# maturin generate-ci github
#
name: CI
on:
push:
branches:
- main
- master
tags:
- '*'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
linux:
runs-on: ubuntu-latest
strategy:
matrix:
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
windows:
runs-on: windows-latest
strategy:
matrix:
target: [x64, x86]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
architecture: ${{ matrix.target }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
macos:
runs-on: macos-latest
strategy:
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
release:
name: Release
runs-on: ubuntu-latest
if: "startsWith(github.ref, 'refs/tags/')"
needs: [linux, windows, macos, sdist]
steps:
- uses: actions/download-artifact@v3
with:
name: wheels
- name: Publish to PyPI
uses: PyO3/maturin-action@v1
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
with:
command: upload
args: --skip-existing *

77
.gitignore vendored Normal file
View file

@ -0,0 +1,77 @@
/target
/ext/codemp_client/*
# Byte-compiled / optimized / DLL files
__pycache__/
.pytest_cache/
*.py[cod]
# cargo
*.lock
#sublime
*.sublime-commands
*.sublime-project
# Distribution / packaging
.Python
.venv/
env/
bin/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
include/
man/
venv/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
pip-selfcheck.json
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Rope
.ropeproject
# Django stuff:
*.log
*.pot
.DS_Store
# Sphinx documentation
docs/_build/
# PyCharm
.idea/
# VSCode
.vscode/
# Pyenv
#.python-version

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "ext/sublime_asyncio"]
path = ext/sublime_asyncio
url = git@github.com:sublimelsp/sublime_asyncio.git

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.8

18
Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "CodempClient-Sublime"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "codemp_client"
crate-type = ["cdylib"]
[dependencies]
codemp = { path = "./ext/codemp" }
pyo3 = { version = "0.19", features = ["extension-module"] }
pyo3-asyncio = { version = "0.19", features = ["tokio-runtime"] }
tokio = "1.29.1"
[build-dependencies]
pyo3-build-config = "0.19.2"

View file

@ -0,0 +1 @@
8d18661818436ebf65d16f9447601b123aef410e

5
build.rs Normal file
View file

@ -0,0 +1,5 @@
use pyo3_build_config;
fn main() {
pyo3_build_config::add_extension_module_link_args();
}

20
build.sh Executable file
View file

@ -0,0 +1,20 @@
#!/bin/sh
ROOT_DIR="$(pwd)"
BUILD_DIR="$ROOT_DIR/target/debug/deps"
FILENAME="libcodemp_client"
TARGET_DIR="$ROOT_DIR/bindings"
TARGET_NAME="codemp_client"
TARGET_EXT="$(python -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX"))')"
FULL_TARGET="${TARGET_NAME}${TARGET_EXT}"
PYO3_PYTHON="$(pyenv which python)"
PYTHON_SYS_EXECUTABLE="$PYO3_PYTHON"
CARGO_FEATURES="pyo3/extension-module"
env PYO3_PYTHON="${PYO3_PYTHON}" PYTHON_SYS_EXECUTABLE="$PYO3_PYTHON" cargo build --features "$CARGO_FEATURES"
[[ -f "$TARGET_DIR/$FUll_TARGET" ]] && echo "$FILE exists."
ditto "$BUILD_DIR/${FILENAME}.dylib" "$TARGET_DIR/$FULL_TARGET"

1
ext/sublime_asyncio Submodule

@ -0,0 +1 @@
Subproject commit 6a8eb2dbd36c72b34eb0078bc1b3cb96f1692acb

254
plugin.py Normal file
View file

@ -0,0 +1,254 @@
import sublime
import sublime_plugin
# import Codemp.codemp_client as codemp
from Codemp.src.codemp_client import *
import Codemp.ext.sublime_asyncio as sublime_asyncio
import asyncio
import time
# UGLYYYY, find a way to not have global variables laying around.
_tasks = []
_client = CodempClient()
_cursor_controller = None
_op_controller = None
_setting_key = "codemp_buffer"
def store_task(name = None):
def store_named_task(task):
global _tasks
task.set_name(name)
_tasks.append(task)
return store_named_task
def plugin_loaded():
sublime_asyncio.acquire() # instantiate and start a global event loop.
class CodempClientViewEventListener(sublime_plugin.ViewEventListener):
@classmethod
def is_applicable(cls, settings):
return "codemp_buffer" in settings
def on_selection_modified_async(self):
global _cursor_controller
if _cursor_controller:
sublime_asyncio.dispatch(send_selection(self.view))
def on_close(self):
self.view.settings()["codemp_buffer"] = False
class CodempClientTextChangeListener(sublime_plugin.TextChangeListener):
@classmethod
def is_applicable(cls, buffer):
for view in buffer.views():
if "codemp_buffer" in view.settings():
return True
return False
def on_text_changed(self, changes):
global _op_controller
if _op_controller:
for change in changes:
sublime_asyncio.dispatch(apply_changes(change))
async def apply_changes(change):
global _op_controller
text = change.str
skip = change.a.pt
if change.len_utf8 == 0: # we are inserting new text.
tail = len(_op_controller.get_content()) - skip
else: # we are changing an existing region of text of length len_utf8
tail = len(_op_controller.get_content()) - skip - change.len_utf8
tail_skip = len(_op_controller.get_content()) - tail
print("[buff change]", skip, text, tail_skip)
await _op_controller.apply(skip, text, tail)
async def make_connection(server_host):
global _client
if _client.ready:
sublime.message_dialog("A connection already exists.")
return
sublime.status_message("[codemp] Connecting to {}".format(server_host))
print("[codemp] Connecting to {}".format(server_host))
await _client.connect(server_host)
id = await _client.get_id()
sublime.status_message("[codemp] Connected with client ID: {}".format(id))
print("[codemp] Connected with client ID: ", id)
async def move_cursor(usr, caller, path, start, end):
print(usr, caller, start, end)
async def sync_buffer(caller, start, end, txt):
print("[buffer]", caller, start, end, txt)
async def share_buffer(buffer):
global _client
global _cursor_controller
global _op_controller
if not _client.ready:
sublime.error_message("No connected client.")
return
sublime.status_message("[codemp] Sharing buffer {}".format(buffer))
print("[codemp] Sharing buffer {}".format(buffer))
view = get_matching_view(buffer)
contents = get_contents(view)
created = await _client.create(view.file_name(), contents)
if not created:
sublime.error_message("Could not share buffer.")
return
_op_controller = await _client.attach(buffer)
_op_controller.callback(sync_buffer, _client.id)
_cursor_controller = await _client.listen()
_cursor_controller.callback(move_cursor, _client.id)
if not _cursor_controller:
sublime.error_message("Could not subsribe a listener.")
return
if not _op_controller:
sublime.error_message("Could not attach to the buffer.")
return
sublime.status_message("[codemp] Listening")
print("[codemp] Listening")
view.settings()["codemp_buffer"] = True
async def join_buffer(window, buffer):
global _client
global _cursor_controller
global _op_controller
if not _client.ready:
sublime.error_message("No connected client.")
return
view = get_matching_view(buffer)
sublime.status_message("[codemp] Joining buffer {}".format(buffer))
print("[codemp] Joining buffer {}".format(buffer))
_op_controller = await _client.attach(buffer)
content = _op_controller.get_content()
view.run_command("codemp_replace_view", {"content": content})
_cursor_controller = await _client.listen()
_cursor_controller.callback(move_cursor)
if not _cursor_controller:
sublime.error_message("Could not subsribe a listener.")
return
if not _op_controller:
sublime.error_message("Could not attach to the buffer.")
return
sublime.status_message("[codemp] Listening")
print("[codemp] Listening")
view.settings()["codemp_buffer"] = True
async def send_selection(view):
global _cursor_controller
path = view.file_name()
region = view.sel()[0] # TODO: only the last placed cursor/selection.
start = view.rowcol(region.begin()) #only counts UTF8 chars
end = view.rowcol(region.end())
await _cursor_controller.send(path, start, end)
def get_contents(view):
r = sublime.Region(0, view.size())
return view.substr(r)
def get_matching_view(path):
for window in sublime.windows():
for view in window.views():
if view.file_name() == path:
return view
# See the proxy command class at the bottom
class CodempConnectCommand(sublime_plugin.WindowCommand):
def run(self, server_host):
sublime_asyncio.dispatch(make_connection(server_host))
# see proxy command at the bottom
class CodempShareCommand(sublime_plugin.WindowCommand):
def run(self, buffer):
sublime_asyncio.dispatch(share_buffer(buffer))
# see proxy command at the bottom
class CodempJoinCommand(sublime_plugin.WindowCommand):
def run(self, buffer):
sublime_asyncio.dispatch(join_buffer(self.window, buffer))
class CodempPopulateView(sublime_plugin.TextCommand):
def run(self, edit, content):
self.view.replace(edit, sublime.Region(0, self.view.size()), content)
class ProxyCodempConnectCommand(sublime_plugin.WindowCommand):
# on_window_command, does not trigger when called from the command palette
# See: https://github.com/sublimehq/sublime_text/issues/2234
def run(self, **kwargs):
self.window.run_command("codemp_connect", kwargs)
def input(self, args):
if 'server_host' not in args:
return ServerHostInputHandler()
def input_description(self):
return 'Server host:'
class ProxyCodempShareCommand(sublime_plugin.WindowCommand):
# on_window_command, does not trigger when called from the command palette
# See: https://github.com/sublimehq/sublime_text/issues/2234
def run(self, **kwargs):
self.window.run_command("codemp_share", kwargs)
def input(self, args):
if 'buffer' not in args:
return BufferInputHandler()
def input_description(self):
return 'Share Buffer:'
class ProxyCodempJoinCommand(sublime_plugin.WindowCommand):
# on_window_command, does not trigger when called from the command palette
# See: https://github.com/sublimehq/sublime_text/issues/2234
def run(self, **kwargs):
self.window.run_command("codemp_join", kwargs)
def input(self, args):
if 'buffer' not in args:
return BufferInputHandler()
def input_description(self):
return 'Join Buffer:'
class BufferInputHandler(sublime_plugin.ListInputHandler):
def list_items(self):
ret_list = []
for window in sublime.windows():
for view in window.views():
if view.file_name():
ret_list.append(view.file_name())
return ret_list
class ServerHostInputHandler(sublime_plugin.TextInputHandler):
def initial_text(self):
return "http://[::1]:50051"

16
pyproject.toml Normal file
View file

@ -0,0 +1,16 @@
[build-system]
requires = ["maturin>=1.1,<2.0"]
build-backend = "maturin"
[project]
name = "Codemp Client"
requires-python = ">=3.7"
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
[tool.maturin]
features = ["pyo3/extension-module"]

71
src/codemp_client.py Normal file
View file

@ -0,0 +1,71 @@
import asyncio
import Codemp.bindings.codemp_client as libcodemp
class CodempClient():
def __init__(self):
self.handle = None
self.id = None
self.ready = False
async def connect(self, server_host):
self.handle = await libcodemp.connect(server_host)
self.id = await self.handle.get_id()
self.ready = True
def disconnect(self):
self.handle = None
self.id = None
self.ready = False
# some code that tells the server to unsubscribe stuff as well.
async def get_id(self):
if self.ready and not self.id:
self.id = await self.handle.get_id()
return self.id
elif self.ready:
return self.id
else:
raise RuntimeError("Attemp to get id without an established connection.")
async def create(self, path, content=None):
if self.ready:
return await self.handle.create(path, content)
else:
raise RuntimeError("Attemp to create a buffer without a connection.")
async def listen(self):
if self.ready:
return CursorController(await self.handle.listen())
else:
raise RuntimeError("Attempt to listen without a connection.")
async def attach(self, path):
if self.ready:
return ContentController(await self.handle.attach(path))
else:
raise RuntimeError("Attempt to attach without a connection.")
class CursorController():
def __init__(self, handle):
self.handle = handle
async def send(self, path, start, end):
await self.handle.send(path, start, end)
def callback(self, coro, id):
self.handle.callback(coro, id)
class ContentController():
def __init__(self, handle):
self.handle = handle
def get_content(self):
return self.handle.content()
async def apply(self, skip, text, tail):
return await self.handle.apply(skip, text, tail)
def callback(self, coro, id):
self.handle.callback(coro, id)

234
src/lib.rs Normal file
View file

@ -0,0 +1,234 @@
use std::{sync::Arc, error::Error, borrow::BorrowMut};
use codemp::{
client::CodempClient,
controller::{cursor::{CursorSubscriber, CursorControllerHandle},
buffer::{OperationControllerHandle, OperationControllerSubscriber}},
proto::Position, factory::OperationFactory, tokio::sync::Mutex
};
use pyo3::{
prelude::*,
exceptions::PyConnectionError,
types::{PyBool, PyString}
};
#[pyfunction]
fn connect<'a>(py: Python<'a>, dest: String) -> PyResult<&'a PyAny> {
// construct a python coroutine
pyo3_asyncio::tokio::future_into_py(py, async move {
match CodempClient::new(dest.as_str()).await {
Ok(c) => {
Python::with_gil(|py|{
let cc: PyClientHandle = c.into();
let handle = Py::new(py, cc)?;
Ok(handle)
})
},
Err(e) => { Err(PyConnectionError::new_err(e.source().unwrap().to_string())) }
}
})
}
#[pyclass]
#[derive(Clone)]
struct PyClientHandle(Arc<Mutex<CodempClient>>);
impl From::<CodempClient> for PyClientHandle {
fn from(value: CodempClient) -> Self {
PyClientHandle(Arc::new(Mutex::new(value)))
}
}
#[pymethods]
impl PyClientHandle {
fn get_id<'a>(&self, py: Python<'a>) -> PyResult<&'a PyAny> {
let rc = self.0.clone();
pyo3_asyncio::tokio::future_into_py(py, async move {
let binding = rc.lock().await;
Python::with_gil(|py| {
let id: Py<PyString> = PyString::new(py, binding.id()).into();
Ok(id)
})
})
}
fn create<'a>(&self, py: Python<'a>, path: String, content: Option<String>) -> PyResult<&'a PyAny> {
let rc = self.0.clone();
pyo3_asyncio::tokio::future_into_py(py, async move {
match rc.lock().await.create(path, content).await {
Ok(accepted) => {
Python::with_gil(|py| {
let accepted: Py<PyBool> = PyBool::new(py, accepted).into();
Ok(accepted)
})
},
Err(e) => { Err(PyConnectionError::new_err(e.source().unwrap().to_string())) }
}
})
}
fn listen<'a>(&self, py: Python<'a>) -> PyResult<&'a PyAny> {
let rc = self.0.clone();
pyo3_asyncio::tokio::future_into_py(py, async move {
match rc.lock().await.listen().await {
Ok(controller) => {
Python::with_gil(|py| {
let cc: PyControllerHandle = controller.into();
let contr = Py::new(py, cc)?;
Ok(contr)
})
},
Err(e) => {Err(PyConnectionError::new_err(e.source().unwrap().to_string()))}
}
})
}
fn attach<'a>(&self, py: Python<'a>, path: String) -> PyResult<&'a PyAny> {
let rc = self.0.clone();
let uri = path.clone();
pyo3_asyncio::tokio::future_into_py(py, async move {
match rc.lock().await.attach(uri).await {
Ok(factory) => {
Python::with_gil(|py| {
let ff: PyOperationsHandle = factory.into();
let fact = Py::new(py, ff)?;
Ok(fact)
})
},
Err(e) => {Err(PyConnectionError::new_err(e.source().unwrap().to_string()))}
}
})
}
}
impl From::<CursorControllerHandle> for PyControllerHandle {
fn from(value: CursorControllerHandle) -> Self {
PyControllerHandle(value)
}
}
impl From::<OperationControllerHandle> for PyOperationsHandle {
fn from(value: OperationControllerHandle) -> Self {
PyOperationsHandle(value)
}
}
#[pyclass]
struct PyControllerHandle(CursorControllerHandle);
#[pymethods]
impl PyControllerHandle {
fn callback<'a>(&'a self, py: Python<'a>, coro_py: Py<PyAny>, caller_id: Py<PyString>) -> PyResult<&'a PyAny> {
let mut rc = self.0.clone();
let cb = coro_py.clone();
// We want to start polling the ControlHandle and call the callback every time
// we have something.
pyo3_asyncio::tokio::future_into_py(py, async move {
while let Some(op) = rc.poll().await {
let start = op.start.unwrap_or(Position { row: 0, col: 0});
let end = op.end.unwrap_or(Position { row: 0, col: 0});
let cb_fut = Python::with_gil(|py| -> PyResult<_> {
let args = (op.user, caller_id.clone(), op.buffer, (start.row, start.col), (end.row, end.col));
let coro = cb.call1(py, args)?;
pyo3_asyncio::tokio::into_future(coro.into_ref(py))
})?;
cb_fut.await?;
}
Ok(())
})
} // to call after polling cursor movements.
fn send<'a>(&self, py: Python<'a>, path: String, start: (i32, i32), end: (i32, i32)) -> PyResult<&'a PyAny> {
let rc = self.0.clone();
pyo3_asyncio::tokio::future_into_py(py, async move {
let startpos = Position { row: start.0, col: start.1 };
let endpos = Position { row: end.0, col: end.1 };
rc.send(path.as_str(), startpos, endpos).await;
Ok(Python::with_gil(|py| py.None()))
})
} // when we change the cursor ourselves.
}
#[pyclass]
struct PyOperationsHandle(OperationControllerHandle);
#[pymethods]
impl PyOperationsHandle {
fn callback<'a>(&'a self, py: Python<'a>, coro_py: Py<PyAny>, caller_id: Py<PyString>) -> PyResult<&'a PyAny> {
let mut rc = self.0.clone();
let cb = coro_py.clone();
// We want to start polling the ControlHandle and call the callback every time
// we have something.
pyo3_asyncio::tokio::future_into_py(py, async move {
while let Some(edit) = rc.poll().await {
let start = edit.span.start;
let end = edit.span.end;
let text = edit.content;
let cb_fut = Python::with_gil(|py| -> PyResult<_> {
let args = (caller_id.clone(), start, end, text);
let coro = cb.call1(py, args)?;
pyo3_asyncio::tokio::into_future(coro.into_ref(py))
})?;
cb_fut.await?;
}
Ok(())
})
} //to call after polling text changes
fn content(&self, py: Python<'_>) -> PyResult<Py<PyString>> {
let cont: Py<PyString> = PyString::new(py, self.0.content().as_str()).into();
Ok(cont)
}
fn apply<'a>(&self, py: Python<'a>, skip: usize, text: String, tail: usize) -> PyResult<&'a PyAny>{
let rc = self.0.clone();
pyo3_asyncio::tokio::future_into_py(py, async move {
match rc.delta(skip, text.as_str(), tail) {
Some(op) => { rc.apply(op).await; Ok(()) },
None => Err(PyConnectionError::new_err("delta failed"))
}
// if let Some(op) = rc.delta(skip, text.as_str(), tail) {
// rc.apply(op).await;
// Python::with_gil(|py| {
// let accepted: Py<PyBool> = PyBool::new(py, true).into();
// Ok(accepted)
// })
// } else {
// Python::with_gil(|py| {
// let accepted: Py<PyBool> = PyBool::new(py, false).into();
// Ok(accepted)
// })
// }
})
} //after making text mofications.
}
// Python module
#[pymodule]
fn codemp_client(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(connect, m)?)?;
m.add_class::<PyClientHandle>()?;
m.add_class::<PyControllerHandle>()?;
m.add_class::<PyOperationsHandle>()?;
Ok(())
}

26
test.py Normal file
View file

@ -0,0 +1,26 @@
import bindings.codemp_client
import asyncio
HOST = "http://alemi.dev:50051"
LOCAL_HOST = "http://[::1]:50051"
async def get_handle(host):
return await bindings.codemp_client.connect(host)
async def get_id(handle):
return await handle.get_id()
async def create_buffer(handle):
return await handle.create("test.py")
async def main():
handle = await bindings.codemp_client.connect(HOST)
print("Client Handle: ", handle)
id = await handle.get_id()
print("Client ID: ", id)
buffer_created = await create_buffer(handle)
print("buffer_created: ", buffer_created)
asyncio.run(main())