diff --git a/.github/workflows/manylinux.yml b/.github/workflows/manylinux.yml index 5a77483..7d68d58 100644 --- a/.github/workflows/manylinux.yml +++ b/.github/workflows/manylinux.yml @@ -19,7 +19,7 @@ jobs: - name: maturin-build run: | rustup default 1.60.0 - cd cffi && maturin build --release + cd python && maturin build --release - uses: actions/upload-artifact@v1 name: upload-wheel with: @@ -29,5 +29,5 @@ jobs: if: github.event_name == 'release' env: MATURIN_PASSWORD: ${{ secrets.QUIZDOWN_PYPI_TOKEN }} - run: cd cffi && maturin publish -b cffi --username __token__ + run: cd python && maturin publish --username __token__ diff --git a/.github/workflows/osx.yml b/.github/workflows/osx.yml index a64455d..8ac22fc 100644 --- a/.github/workflows/osx.yml +++ b/.github/workflows/osx.yml @@ -33,26 +33,26 @@ jobs: cargo test --release - name: py-install run: | - pip install -r cffi/requirements.txt - pip install --upgrade pip + pip install -U pip + pip install -r python/requirements.txt pip install maturin bs4 - name: build-wheel - working-directory: ./cffi + working-directory: ./python run: | maturin build --release pip install ../target/wheels/quizdown*.whl - - name: py-unittest - working-directory: ./cffi - run: | - python -I -m unittest discover -s tests -v - uses: actions/upload-artifact@v1 name: upload-wheel with: name: ${{ matrix.platform }}-wheel path: target/wheels/ + - name: py-unittest + working-directory: ./python + run: | + python -I -m unittest discover -s tests -v - name: '[on-create] pypi-publish' if: github.event_name == 'release' - working-directory: ./cffi + working-directory: ./python env: MATURIN_PASSWORD: ${{ secrets.QUIZDOWN_PYPI_TOKEN }} - run: maturin publish -b cffi --username __token__ + run: maturin publish --username __token__ diff --git a/.gitignore b/.gitignore index 4ce7255..0bf8f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ venv .mypy_cache __pycache__ cffi/quizdown/quizdown +*.so diff --git a/Cargo.toml b/Cargo.toml index 8697eee..4215ec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,4 +3,4 @@ opt-level = 2 [workspace] -members = ["lib", "cli", "cffi", "wasm"] +members = ["lib", "cli", "wasm", "python"] diff --git a/cffi/quizdown/__init__.py b/cffi/quizdown/__init__.py deleted file mode 100644 index 0de1abc..0000000 --- a/cffi/quizdown/__init__.py +++ /dev/null @@ -1,108 +0,0 @@ -from .quizdown import lib, ffi -from .types import Quiz, Question, QOption -from typing import List, Optional, Dict, Any, Set -import json - - -def _rust_str(result) -> str: - """ - Make a copy of a rust String and immediately free it! - """ - try: - txt = ffi.cast("char *", result) - txt = ffi.string(txt).decode("UTF-8") - return txt - finally: - assert lib.free_str(result) - - -def _raise_error_str(rust_error_string: Optional[str]): - if rust_error_string is None: - return - if "{" in rust_error_string: - response = json.loads(rust_error_string) - if "error" in response and "context" in response: - raise ValueError("{0}: {1}".format(response["error"], response["context"])) - else: - raise ValueError(rust_error_string) - - -def _handle_ffi_result(ffi_result): - """ - This handles the logical-OR struct of the FFIResult { error_message, success } - where both the wrapper and the error_message will be freed by the end of this function. - - The success pointer is returned or an error is raised! - """ - if ffi_result == ffi.NULL: - raise ValueError("FFIResult should not be NULL") - - error = None - success = None - if ffi_result.error_message != ffi.NULL: - error = _rust_str(ffi_result.error_message) - if ffi_result.success != ffi.NULL: - success = ffi_result.success - lib.free_ffi_result(ffi_result) - - # maybe crash here! - if error is not None: - _raise_error_str(error) - return None - - # return the success pointer otherwise! - return success - - -def available_themes() -> Set[str]: - return set(_rust_str(lib.available_themes()).split("\t")) - - -def default_config() -> Dict[str, Any]: - return json.loads(_rust_str(lib.default_config())) - - -AVAILABLE_FORMATS = ["HtmlSnippet", "HtmlFull", "MoodleXml", "JSON"] - - -def quizdown_render( - input: str, name: str = "quizdown", format: str = "MoodleXml", config=None -) -> str: - """ - Parse some quizdown text into a sequence of questions. - - raises: ValueError - """ - if config is None: - config = default_config() - - config_str = "" - if type(config) is dict: - config_str = json.dumps(config) - elif type(config) is str: - config_str = config - else: - raise ValueError(config) - - if format not in AVAILABLE_FORMATS: - raise ValueError(format) - of = json.dumps({format: None}) - return _rust_str( - _handle_ffi_result( - lib.parse_quizdown( - input.encode("UTF-8"), - name.encode("UTF-8"), - of.encode("UTF-8"), - config_str.encode("UTF-8"), - ) - ) - ) - - -def quizdown_to_py(input: str, name: str) -> Quiz: - """ - Parse some quizdown text into a Quiz object. - - raises: ValueError - """ - return Quiz.from_dict(json.loads(quizdown_render(input, name, "JSON"))) diff --git a/cffi/src/lib.rs b/cffi/src/lib.rs deleted file mode 100644 index 650226e..0000000 --- a/cffi/src/lib.rs +++ /dev/null @@ -1,131 +0,0 @@ -#![crate_type = "dylib"] - -use libc::{c_char, c_void}; -use quizdown_lib as qd; -use std::error::Error; -use std::ffi::{CStr, CString}; -use std::ptr; - -#[macro_use] -extern crate serde_derive; - -/// This is a JSON-API, not a C-API, really. -#[derive(Serialize, Deserialize)] -struct ErrorMessage { - error: String, - context: String, -} - -#[repr(C)] -pub struct FFIResult { - /// Non-null if there's an error. - pub error_message: *const c_void, - /// Non-null if we succeeded. - pub success: *const c_void, -} - -impl Default for FFIResult { - fn default() -> Self { - FFIResult { - error_message: ptr::null(), - success: ptr::null(), - } - } -} - -/// Accept a string parameter! -pub(crate) fn accept_str(name: &str, input: *const c_void) -> Result<&str, Box> { - if input.is_null() { - Err(format!("NULL pointer: {}", name))?; - } - let input: &CStr = unsafe { CStr::from_ptr(input as *const c_char) }; - Ok(input - .to_str() - .map_err(|_| format!("Could not parse {} pointer as UTF-8 string!", name))?) -} - -/// Internal helper: convert string reference to pointer to be passed to Python/C. Heap allocation. -pub(crate) fn return_string(output: &str) -> *const c_void { - let c_output: CString = CString::new(output).expect("Conversion to CString should succeed!"); - CString::into_raw(c_output) as *const c_void -} - -pub(crate) fn result_to_ffi(rust_result: Result>) -> *const FFIResult { - let mut c_result = Box::new(FFIResult::default()); - match rust_result { - Ok(item) => { - c_result.success = return_string(&item); - } - Err(e) => { - let error_message = serde_json::to_string(&ErrorMessage { - error: "error".to_string(), - context: format!("{:?}", e), - }) - .unwrap(); - c_result.error_message = return_string(&error_message); - } - }; - Box::into_raw(c_result) -} - -#[no_mangle] -pub extern "C" fn available_themes() -> *const c_void { - let mut output = String::new(); - for theme in qd::list_themes() { - if output.len() > 0 { - output.push('\t'); - } - output.push_str(&theme); - } - return_string(&output) -} - -#[no_mangle] -pub extern "C" fn default_config() -> *const c_void { - return_string(&serde_json::to_string(&qd::Config::default()).unwrap()) -} - -/// This is our main interface to the library. -#[no_mangle] -pub extern "C" fn parse_quizdown( - text: *const c_void, - name: *const c_void, - format: *const c_void, - config: *const c_void, -) -> *const FFIResult { - result_to_ffi(try_parse_quizdown(text, name, format, config)) -} - -fn try_parse_quizdown( - text: *const c_void, - name: *const c_void, - format: *const c_void, - config: *const c_void, -) -> Result> { - let text = accept_str("text-to-parse", text)?; - let name = accept_str("quiz_name", name)?; - let format: qd::OutputFormat = serde_json::from_str(accept_str("format", format)?)?; - let config: qd::Config = serde_json::from_str(accept_str("config", config)?)?; - - let parsed = qd::process_questions_str(text, Some(config)) - .map_err(|e| format!("Parsing Error: {}", e))?; - Ok(format - .render(name, &parsed) - .map_err(|e| format!("Rendering Error: {}", e))?) -} - -/// Returns true if it received a non-null string to free. -#[no_mangle] -pub extern "C" fn free_str(originally_from_rust: *mut c_void) -> bool { - if originally_from_rust.is_null() { - return false; - } - let _will_drop: CString = unsafe { CString::from_raw(originally_from_rust as *mut c_char) }; - true -} - -/// Note: not-recursive. Free Error Message or Result Manually! -#[no_mangle] -pub extern "C" fn free_ffi_result(originally_from_rust: *mut FFIResult) { - let _will_drop: Box = unsafe { Box::from_raw(originally_from_rust) }; -} diff --git a/cffi/01.html b/python/01.html similarity index 100% rename from cffi/01.html rename to python/01.html diff --git a/cffi/01.moodle b/python/01.moodle similarity index 100% rename from cffi/01.moodle rename to python/01.moodle diff --git a/cffi/Cargo.toml b/python/Cargo.toml similarity index 81% rename from cffi/Cargo.toml rename to python/Cargo.toml index cf5471c..5d50615 100644 --- a/cffi/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quizdown" -version = "0.3.1" +version = "0.3.2" authors = ["John Foley "] readme = "../README.md" description = "A markdown subset for quickly making multiple-choice questions." @@ -13,14 +13,15 @@ name = "quizdown" crate-type = ["cdylib"] [dependencies] -libc = "0.2" serde = "1" serde_json = "1" serde_derive = "1" quizdown_lib = {path = "../lib"} +pyo3 = {version="0.13", features=["extension-module", "abi3-py36"]} + [package.metadata.maturin] -requires-dist = ["cffi", "attrs", "jinja2"] +requires-dist = ["attrs", "jinja2"] classifier = [ "Programming Language :: Python :: 3.6", "Operating System :: OS Independent", diff --git a/cffi/build.sh b/python/build.sh similarity index 79% rename from cffi/build.sh rename to python/build.sh index 9f349c3..40db4d4 100755 --- a/cffi/build.sh +++ b/python/build.sh @@ -5,7 +5,7 @@ set -eu rm -rf quizdown/quizdown source venv/bin/activate pip install -q -r dev-requirements.txt -maturin build -b cffi -maturin develop -b cffi +maturin build +maturin develop python -m unittest discover -s tests python -m quizdown --help diff --git a/cffi/dev-requirements.txt b/python/dev-requirements.txt similarity index 60% rename from cffi/dev-requirements.txt rename to python/dev-requirements.txt index 4ebe185..e9d5f32 100644 --- a/cffi/dev-requirements.txt +++ b/python/dev-requirements.txt @@ -1,4 +1,4 @@ -cffi maturin black -mypy \ No newline at end of file +mypy +bs4 diff --git a/cffi/package.sh b/python/package.sh similarity index 72% rename from cffi/package.sh rename to python/package.sh index e8951c6..10cb030 100755 --- a/cffi/package.sh +++ b/python/package.sh @@ -6,6 +6,6 @@ rm -rf quizdown/quizdown source venv/bin/activate pip install -q -r requirements.txt pip install -q -r dev-requirements.txt -maturin build -b cffi --release -maturin develop -b cffi --release +maturin build --release +maturin develop --release python -m quizdown --help diff --git a/python/quizdown/__init__.py b/python/quizdown/__init__.py new file mode 100644 index 0000000..ef082c5 --- /dev/null +++ b/python/quizdown/__init__.py @@ -0,0 +1,50 @@ +import quizdown.quizdown as lib +from .types import Quiz, Question, QOption +from typing import List, Optional, Dict, Any, Set +import json + + +def available_themes() -> Set[str]: + return set(lib.available_themes()) + + +def default_config() -> Dict[str, Any]: + return json.loads(lib.default_config()) + + +AVAILABLE_FORMATS = ["HtmlSnippet", "HtmlFull", "MoodleXml", "JSON"] + + +def quizdown_render( + input: str, name: str = "quizdown", format: str = "MoodleXml", config=None +) -> str: + """ + Parse some quizdown text into a sequence of questions. + + raises: ValueError + """ + if config is None: + config = default_config() + + config_str = "" + if type(config) is dict: + config_str = json.dumps(config) + elif type(config) is str: + config_str = config + else: + raise ValueError(config) + + if format not in AVAILABLE_FORMATS: + raise ValueError(format) + of = json.dumps({format: None}) + return lib.try_parse_quizdown(input, name, of, config_str) + + + +def quizdown_to_py(input: str, name: str) -> Quiz: + """ + Parse some quizdown text into a Quiz object. + + raises: ValueError + """ + return Quiz.from_dict(json.loads(quizdown_render(input, name, "JSON"))) diff --git a/cffi/quizdown/__main__.py b/python/quizdown/__main__.py similarity index 100% rename from cffi/quizdown/__main__.py rename to python/quizdown/__main__.py diff --git a/cffi/quizdown/qti_format.py b/python/quizdown/qti_format.py similarity index 100% rename from cffi/quizdown/qti_format.py rename to python/quizdown/qti_format.py diff --git a/cffi/quizdown/templates/__init__.py b/python/quizdown/templates/__init__.py similarity index 100% rename from cffi/quizdown/templates/__init__.py rename to python/quizdown/templates/__init__.py diff --git a/cffi/quizdown/templates/qti-manifest.j2 b/python/quizdown/templates/qti-manifest.j2 similarity index 100% rename from cffi/quizdown/templates/qti-manifest.j2 rename to python/quizdown/templates/qti-manifest.j2 diff --git a/cffi/quizdown/templates/qti-quiz-meta.j2 b/python/quizdown/templates/qti-quiz-meta.j2 similarity index 100% rename from cffi/quizdown/templates/qti-quiz-meta.j2 rename to python/quizdown/templates/qti-quiz-meta.j2 diff --git a/cffi/quizdown/templates/qti-quiz.j2 b/python/quizdown/templates/qti-quiz.j2 similarity index 100% rename from cffi/quizdown/templates/qti-quiz.j2 rename to python/quizdown/templates/qti-quiz.j2 diff --git a/cffi/quizdown/types.py b/python/quizdown/types.py similarity index 100% rename from cffi/quizdown/types.py rename to python/quizdown/types.py diff --git a/cffi/requirements.txt b/python/requirements.txt similarity index 100% rename from cffi/requirements.txt rename to python/requirements.txt diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 0000000..bf53a4f --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,41 @@ +use pyo3::exceptions::PyValueError; +use pyo3::wrap_pyfunction; +use quizdown_lib as qd; + +use pyo3::prelude::*; +use pyo3::PyErr; + +#[pyfunction] +pub fn default_config() -> PyResult { + Ok(serde_json::to_string(&qd::Config::default()).unwrap()) +} + +#[pyfunction] +pub fn available_themes() -> PyResult> { + Ok(qd::list_themes()) +} + +fn stringify_err(context: &str, e: E) -> PyErr { + PyValueError::new_err(format!("{}: {:?}", context, e)) +} + +#[pyfunction] +pub fn try_parse_quizdown(text: &str, name: &str, format: &str, config: &str) -> PyResult { + let format: qd::OutputFormat = + serde_json::from_str(format).map_err(|e| stringify_err("format invalid", e))?; + let config: qd::Config = + serde_json::from_str(config).map_err(|e| stringify_err("config invalid", e))?; + let parsed = qd::process_questions_str(text, Some(config)) + .map_err(|e| stringify_err("Parsing Error", e))?; + Ok(format + .render(name, &parsed) + .map_err(|e| stringify_err("Rendering Error", e))?) +} + +#[pymodule] +pub fn quizdown(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(available_themes, m)?)?; + m.add_function(wrap_pyfunction!(default_config, m)?)?; + m.add_function(wrap_pyfunction!(try_parse_quizdown, m)?)?; + Ok(()) +} diff --git a/cffi/tests/test_no_crash.py b/python/tests/test_no_crash.py similarity index 100% rename from cffi/tests/test_no_crash.py rename to python/tests/test_no_crash.py