From 9558dfa268eaa51fc1cc34c3023215c3de31ff37 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 17:48:20 +0200 Subject: [PATCH 01/22] Initial commit --- .gitignore | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 12 +++++++++ LICENSE | 17 ++++++++++++ README.md | 8 ++++++ pyproject.toml | 30 +++++++++++++++++++++ src/lib.rs | 14 ++++++++++ 6 files changed, 153 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af3ca5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# 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 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..836f9c2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pycases" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "pycases" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.19.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..969d061 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..05538ac --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# pycases + +A case conversion library with Unicode support. + +## License + +This project is licensed under the terms of the MIT license. See +[LICENSE](LICENSE) for more details. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..679636f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.2,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] + +[project] +name = "pycases" +version = "0.0.0" +description = "A case conversion library with Unicode support" +requires-python = ">=3.7" +license = { text = "MIT" } +authors = [{ name = "Ross MacArthur", email = "ross@macarthur.io" }] +readme = "README.md" +keywords = ["convert", "case", "snake", "camel", "pascal"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Rust", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4cd543c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +use pyo3::prelude::*; + +/// Formats the sum of two numbers as string. +#[pyfunction] +fn sum_as_string(a: usize, b: usize) -> PyResult { + Ok((a + b).to_string()) +} + +/// A Python module implemented in Rust. +#[pymodule] +fn pycases(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(sum_as_string, m)?)?; + Ok(()) +} \ No newline at end of file From 9bc81a5e6c7f469404c4431b8e6a1a2307f818ea Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 20:23:52 +0200 Subject: [PATCH 02/22] Implement transform function and all cases --- Cargo.lock | 273 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 +- cases.pyi | 82 +++++++++++++ src/lib.rs | 114 ++++++++++++++++-- src/transform.rs | 110 ++++++++++++++++++ tests/test_cases.py | 95 +++++++++++++++ 6 files changed, 669 insertions(+), 9 deletions(-) create mode 100644 Cargo.lock create mode 100644 cases.pyi create mode 100644 src/transform.rs create mode 100644 tests/test_cases.py diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..afa218c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,273 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cases" +version = "0.1.0" +dependencies = [ + "pyo3", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + +[[package]] +name = "libc" +version = "0.2.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e681a6cfdc4adcc93b4d3cf993749a4552018ee0a9b65fc0ccfad74352c72a38" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076c73d0bc438f7a4ef6fdd0c3bb4732149136abd952b110ac93e4edb13a6ba5" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53cee42e77ebe256066ba8aa77eff722b3bb91f3419177cf4cd0f304d3284d9" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfeb4c99597e136528c6dd7d5e3de5434d1ceaf487436a3f03b2d56b6fc9efd1" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947dc12175c254889edc0c02e399476c2f652b4b9ebd123aa655c224de259536" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unindent" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml index 836f9c2..50b574c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "pycases" +name = "cases" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] -name = "pycases" +name = "cases" crate-type = ["cdylib"] [dependencies] diff --git a/cases.pyi b/cases.pyi new file mode 100644 index 0000000..3b64244 --- /dev/null +++ b/cases.pyi @@ -0,0 +1,82 @@ +""" +A case conversion library with Unicode support, implemented in Rust. + +Word boundaries are defined as follows: + +- A set of consecutive Unicode non-letter/number/symbol e.g. foo _bar is two + words (foo and bar) +- A transition from a lowercase letter to an uppercase letter e.g. fooBar is two + words (foo and Bar) +- The second last uppercase letter in a word with multiple uppercase letters + e.g. FOOBar is two words (FOO and Bar) +""" + + +def to_camel(s: str) -> str: + """ + Convert a string to 'camelCase'. + """ + ... + + +def to_pascal(s: str) -> str: + """ + Convert a string to 'PascalCase'. + """ + ... + + +def to_snake(s: str) -> str: + """ + Convert a string to 'camelCase'. + """ + ... + + +def to_screaming_snake(s: str) -> str: + """ + Convert a string to 'SCREAMING_SNAKE_CASE'. + """ + ... + + +def to_kebab(s: str) -> str: + """ + Convert a string to 'kebab-case'. + """ + ... + + +def to_screaming_kebab(s: str) -> str: + """ + Convert a string to 'SCREAMING-KEBAB-CASE'. + """ + ... + + +def to_train(s: str) -> str: + """ + Convert a string to 'Train-Case'. + """ + ... + + +def to_lower(s: str) -> str: + """ + Convert a string to 'lower case'. + """ + ... + + +def to_title(s: str) -> str: + """ + Convert a string to 'Title Case'. + """ + ... + + +def to_upper(s: str) -> str: + """ + Convert a string to 'UPPER CASE'. + """ + ... diff --git a/src/lib.rs b/src/lib.rs index 4cd543c..afaaeaf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,114 @@ +mod transform; + +use std::fmt; + use pyo3::prelude::*; -/// Formats the sum of two numbers as string. +use crate::transform::{fmt_lower, fmt_title, fmt_upper, transform}; + +/// Convert a string to 'camelCase'. +#[pyfunction] +fn to_camel(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + + let mut first = true; + let word_fn = |buf: &mut String, s: &str| -> fmt::Result { + if first { + first = false; + fmt_lower(buf, s) + } else { + fmt_title(buf, s) + } + }; + + transform(s, &mut buf, word_fn, "").unwrap(); + buf +} + +/// Convert a string to 'PascalCase'. +#[pyfunction] +fn to_pascal(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_title, "").unwrap(); + buf +} + +/// Convert a string to 'snake_case'. +#[pyfunction] +fn to_snake(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_lower, "_").unwrap(); + buf +} + +/// Convert a string to 'SCREAMING_SNAKE_CASE'. +#[pyfunction] +fn to_screaming_snake(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_upper, "_").unwrap(); + buf +} + +/// Convert a string to 'kebab-case'. #[pyfunction] -fn sum_as_string(a: usize, b: usize) -> PyResult { - Ok((a + b).to_string()) +fn to_kebab(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_lower, "-").unwrap(); + buf } -/// A Python module implemented in Rust. +/// Convert a string to 'SCREAMING-KEBAB-CASE'. +#[pyfunction] +fn to_screaming_kebab(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_upper, "-").unwrap(); + buf +} + +/// Convert a string to 'Train-Case'. +#[pyfunction] +fn to_train(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_title, "-").unwrap(); + buf +} + +/// Convert a string to 'lower case'. +#[pyfunction] +fn to_lower(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_lower, " ").unwrap(); + buf +} + +/// Convert a string to 'Title Case'. +#[pyfunction] +fn to_title(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_title, " ").unwrap(); + buf +} + +/// Convert a string to 'UPPER CASE'. +#[pyfunction] +fn to_upper(s: &str) -> String { + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, fmt_upper, " ").unwrap(); + buf +} + +/// A case conversion library with Unicode support, implemented in Rust. #[pymodule] -fn pycases(_py: Python, m: &PyModule) -> PyResult<()> { - m.add_function(wrap_pyfunction!(sum_as_string, m)?)?; +fn cases(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(to_camel, m)?)?; + m.add_function(wrap_pyfunction!(to_pascal, m)?)?; + m.add_function(wrap_pyfunction!(to_snake, m)?)?; + m.add_function(wrap_pyfunction!(to_screaming_snake, m)?)?; + m.add_function(wrap_pyfunction!(to_kebab, m)?)?; + m.add_function(wrap_pyfunction!(to_screaming_kebab, m)?)?; + m.add_function(wrap_pyfunction!(to_train, m)?)?; + m.add_function(wrap_pyfunction!(to_lower, m)?)?; + m.add_function(wrap_pyfunction!(to_title, m)?)?; + m.add_function(wrap_pyfunction!(to_upper, m)?)?; Ok(()) -} \ No newline at end of file +} diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..ca0c88e --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,110 @@ +use std::fmt; +use std::fmt::Write; + +#[derive(Copy, Clone)] +enum State { + Unknown, + Delims, + Lower, + Upper, +} + +// Transform reconstructs the string using the given functions. +pub fn transform(s: &str, buf: &mut B, mut word_fn: W, delim: &str) -> fmt::Result +where + B: Write, + W: FnMut(&mut B, &str) -> fmt::Result, +{ + // when we are on the first word + let mut first = true; + // the byte index of the start of the current word + let mut w0 = 0; + // the byte index of the end of the current word + let mut w1 = None; + // the current state of the word boundary machine + let mut state = State::Unknown; + + let mut write = |w0: usize, w1: Option| -> fmt::Result { + if let Some(w1) = w1 { + if w1 - w0 > 0 { + if first { + first = false; + } else { + buf.write_str(delim)?; + } + word_fn(buf, &s[w0..w1])?; + } + } + Ok(()) + }; + + let mut iter = s.char_indices().peekable(); + + while let Some((i, c)) = iter.next() { + if !c.is_alphanumeric() { + state = State::Delims; + w1 = w1.or(Some(i)); + continue; + } + + let is_lower = c.is_lowercase(); + let is_upper = c.is_uppercase(); + + match state { + State::Delims => { + write(w0, w1)?; + w0 = i; + w1 = None; + } + State::Lower if is_upper => { + write(w0, Some(i))?; + w0 = i; + } + State::Upper + if is_upper && matches!(iter.peek(), Some((_, c2)) if c2.is_lowercase()) => + { + write(w0, Some(i))?; + w0 = i; + } + _ => {} + } + + if is_lower { + state = State::Lower; + } else if is_upper { + state = State::Upper; + } + } + + match state { + State::Delims => write(w0, w1)?, + _ => write(w0, Some(s.len()))?, + } + + Ok(()) +} + +pub fn fmt_lower(buf: &mut W, s: &str) -> fmt::Result { + for c in s.chars() { + write!(buf, "{}", c.to_lowercase())? + } + Ok(()) +} + +pub fn fmt_upper(buf: &mut W, s: &str) -> fmt::Result { + for c in s.chars() { + write!(buf, "{}", c.to_uppercase())? + } + Ok(()) +} + +pub fn fmt_title(buf: &mut W, s: &str) -> fmt::Result { + let mut iter = s.chars(); + if let Some(c) = iter.next() { + write!(buf, "{}", c.to_uppercase())?; + for c in iter { + write!(buf, "{}", c.to_lowercase())?; + } + } + Ok(()) +} diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..82665d5 --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,95 @@ +import cases + +TESTS = [ + ("", "", ""), + ("Test", "test", "test"), + ("test case", "test_case", "testCase"), + (" test case", "test_case", "testCase"), + ("test case ", "test_case", "testCase"), + ("Test Case", "test_case", "testCase"), + (" Test Case", "test_case", "testCase"), + ("Test Case ", "test_case", "testCase"), + ("camelCase", "camel_case", "camelCase"), + ("PascalCase", "pascal_case", "pascalCase"), + ("snake_case", "snake_case", "snakeCase"), + (" Test Case", "test_case", "testCase"), + ("SCREAMING_SNAKE_CASE", "screaming_snake_case", "screamingSnakeCase"), + ("kebab-case", "kebab_case", "kebabCase"), + ("SCREAMING-KEBAB-CASE", "screaming_kebab_case", "screamingKebabCase"), + ("Title Case ", "title_case", "titleCase"), + ("Train-Case ", "train_case", "trainCase"), + ("This is a Test case.", "this_is_a_test_case", "thisIsATestCase"), + ( + "MixedUP CamelCase, with some Spaces", + "mixed_up_camel_case_with_some_spaces", + "mixedUpCamelCaseWithSomeSpaces", + ), + ( + "mixed_up_ snake_case with some _spaces", + "mixed_up_snake_case_with_some_spaces", + "mixedUpSnakeCaseWithSomeSpaces", + ), + ( + "this-contains_ ALLKinds OfWord_Boundaries", + "this_contains_all_kinds_of_word_boundaries", + "thisContainsAllKindsOfWordBoundaries", + ), + ("XΣXΣ baffle", "xσxσ_baffle", "xσxσBaffle"), + ("XMLHttpRequest", "xml_http_request", "xmlHttpRequest"), + ("FIELD_NAME11", "field_name11", "fieldName11"), + ("99BOTTLES", "99bottles", "99bottles"), + ("FieldNamE11", "field_nam_e11", "fieldNamE11"), + ("abc123def456", "abc123def456", "abc123def456"), + ("abc123DEF456", "abc123_def456", "abc123Def456"), + ("abc123Def456", "abc123_def456", "abc123Def456"), + ("abc123DEf456", "abc123_d_ef456", "abc123DEf456"), + ("ABC123def456", "abc123def456", "abc123def456"), + ("ABC123DEF456", "abc123def456", "abc123def456"), + ("ABC123Def456", "abc123_def456", "abc123Def456"), + ("ABC123DEf456", "abc123d_ef456", "abc123dEf456"), + ("ABC123dEEf456FOO", "abc123d_e_ef456_foo", "abc123dEEf456Foo"), + ("abcDEF", "abc_def", "abcDef"), + ("ABcDE", "a_bc_de", "aBcDe"), +] + + +def test_to_camel(): + for s, _, camel in TESTS: + assert cases.to_camel(s) == camel + + +def test_to_pascal(): + assert cases.to_pascal("test case") == "TestCase" + + +def test_to_snake(): + for s, snake, _ in TESTS: + assert cases.to_snake(s) == snake + + +def test_to_screaming_snake(): + assert cases.to_screaming_snake("test case") == "TEST_CASE" + + +def test_to_kebab(): + assert cases.to_kebab("test case") == "test-case" + + +def test_to_screaming_kebab(): + assert cases.to_screaming_kebab("test case") == "TEST-CASE" + + +def test_to_train(): + assert cases.to_train("test case") == "Test-Case" + + +def test_to_lower(): + assert cases.to_lower("Test-case") == "test case" + + +def test_to_title(): + assert cases.to_title("Test-case") == "Test Case" + + +def test_to_upper(): + assert cases.to_upper("test case") == "TEST CASE" From cc5a31f6f086d3d5d8eca02f3c7d9bf5beb06b46 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 21:17:35 +0200 Subject: [PATCH 03/22] Support acronyms for to_camel, to_pascal, and to_train --- cases.pyi | 39 +++++++++--------- src/{transform.rs => core.rs} | 2 +- src/lib.rs | 74 ++++++++++++++++++++++++++--------- tests/test_cases.py | 54 +++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 38 deletions(-) rename src/{transform.rs => core.rs} (96%) diff --git a/cases.pyi b/cases.pyi index 3b64244..b6c8913 100644 --- a/cases.pyi +++ b/cases.pyi @@ -3,78 +3,81 @@ A case conversion library with Unicode support, implemented in Rust. Word boundaries are defined as follows: -- A set of consecutive Unicode non-letter/number/symbol e.g. foo _bar is two - words (foo and bar) -- A transition from a lowercase letter to an uppercase letter e.g. fooBar is two - words (foo and Bar) -- The second last uppercase letter in a word with multiple uppercase letters +- A set of consecutive Unicode non-letter/number + e.g. 'foo _bar' is two words (foo and bar) + +- A transition from a lowercase letter to an uppercase letter + e.g. fooBar is two words (foo and Bar) + + - The second last uppercase letter in a word with multiple uppercase letters e.g. FOOBar is two words (FOO and Bar) + +Some functions accept an optional `acronyms` argument, which is a mapping of +lowercase words to their output. For example: + + >>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML"}) + 'XMLHttpRequest' + >>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) + 'XMLHTTPRequest' + """ +from typing import Optional -def to_camel(s: str) -> str: +def to_camel(s: str, acronyms: Optional[dict[str, str]] = None) -> str: """ Convert a string to 'camelCase'. """ ... - -def to_pascal(s: str) -> str: +def to_pascal(s: str, acronyms: Optional[dict[str, str]] = None) -> str: """ Convert a string to 'PascalCase'. """ ... - def to_snake(s: str) -> str: """ Convert a string to 'camelCase'. """ ... - def to_screaming_snake(s: str) -> str: """ Convert a string to 'SCREAMING_SNAKE_CASE'. """ ... - def to_kebab(s: str) -> str: """ Convert a string to 'kebab-case'. """ ... - def to_screaming_kebab(s: str) -> str: """ Convert a string to 'SCREAMING-KEBAB-CASE'. """ ... - -def to_train(s: str) -> str: +def to_train(s: str, acronyms: Optional[dict[str, str]] = None) -> str: """ Convert a string to 'Train-Case'. """ ... - def to_lower(s: str) -> str: """ Convert a string to 'lower case'. """ ... - -def to_title(s: str) -> str: +def to_title(s: str, acronyms: Optional[dict[str, str]] = None) -> str: """ Convert a string to 'Title Case'. """ ... - def to_upper(s: str) -> str: """ Convert a string to 'UPPER CASE'. diff --git a/src/transform.rs b/src/core.rs similarity index 96% rename from src/transform.rs rename to src/core.rs index ca0c88e..cb1458f 100644 --- a/src/transform.rs +++ b/src/core.rs @@ -10,7 +10,7 @@ enum State { } // Transform reconstructs the string using the given functions. -pub fn transform(s: &str, buf: &mut B, mut word_fn: W, delim: &str) -> fmt::Result +pub fn transform_impl(s: &str, buf: &mut B, mut word_fn: W, delim: &str) -> fmt::Result where B: Write, W: FnMut(&mut B, &str) -> fmt::Result, diff --git a/src/lib.rs b/src/lib.rs index afaaeaf..c441a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,35 +1,48 @@ -mod transform; +mod core; use std::fmt; +use std::fmt::Write; use pyo3::prelude::*; +use pyo3::types::PyDict; -use crate::transform::{fmt_lower, fmt_title, fmt_upper, transform}; +use crate::core::{fmt_lower, fmt_title, fmt_upper, transform_impl}; /// Convert a string to 'camelCase'. #[pyfunction] -fn to_camel(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - +#[pyo3(signature = (s, /, acronyms = None))] +fn to_camel(s: &str, acronyms: Option<&PyDict>) -> String { let mut first = true; let word_fn = |buf: &mut String, s: &str| -> fmt::Result { if first { first = false; fmt_lower(buf, s) } else { - fmt_title(buf, s) + match get_acronym(s, acronyms) { + Some(acronym) => write!(buf, "{}", acronym), + None => fmt_title(buf, s), + } } }; - transform(s, &mut buf, word_fn, "").unwrap(); + let mut buf = String::with_capacity(s.len()); + transform_impl(s, &mut buf, word_fn, "").unwrap(); buf } /// Convert a string to 'PascalCase'. #[pyfunction] -fn to_pascal(s: &str) -> String { +#[pyo3(signature = (s, /, acronyms = None))] +fn to_pascal(s: &str, acronyms: Option<&PyDict>) -> String { + let word_fn = |buf: &mut String, s: &str| -> fmt::Result { + match get_acronym(s, acronyms) { + Some(acronym) => write!(buf, "{}", acronym), + None => fmt_title(buf, s), + } + }; + let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_title, "").unwrap(); + transform_impl(s, &mut buf, word_fn, "").unwrap(); buf } @@ -37,7 +50,7 @@ fn to_pascal(s: &str) -> String { #[pyfunction] fn to_snake(s: &str) -> String { let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_lower, "_").unwrap(); + transform_impl(s, &mut buf, fmt_lower, "_").unwrap(); buf } @@ -45,7 +58,7 @@ fn to_snake(s: &str) -> String { #[pyfunction] fn to_screaming_snake(s: &str) -> String { let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_upper, "_").unwrap(); + transform_impl(s, &mut buf, fmt_upper, "_").unwrap(); buf } @@ -53,7 +66,7 @@ fn to_screaming_snake(s: &str) -> String { #[pyfunction] fn to_kebab(s: &str) -> String { let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_lower, "-").unwrap(); + transform_impl(s, &mut buf, fmt_lower, "-").unwrap(); buf } @@ -61,15 +74,23 @@ fn to_kebab(s: &str) -> String { #[pyfunction] fn to_screaming_kebab(s: &str) -> String { let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_upper, "-").unwrap(); + transform_impl(s, &mut buf, fmt_upper, "-").unwrap(); buf } /// Convert a string to 'Train-Case'. #[pyfunction] -fn to_train(s: &str) -> String { +#[pyo3(signature = (s, /, acronyms = None))] +fn to_train(s: &str, acronyms: Option<&PyDict>) -> String { + let word_fn = |buf: &mut String, s: &str| -> fmt::Result { + match get_acronym(s, acronyms) { + Some(acronym) => write!(buf, "{}", acronym), + None => fmt_title(buf, s), + } + }; + let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_title, "-").unwrap(); + transform_impl(s, &mut buf, word_fn, "-").unwrap(); buf } @@ -77,15 +98,23 @@ fn to_train(s: &str) -> String { #[pyfunction] fn to_lower(s: &str) -> String { let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_lower, " ").unwrap(); + transform_impl(s, &mut buf, fmt_lower, " ").unwrap(); buf } /// Convert a string to 'Title Case'. #[pyfunction] -fn to_title(s: &str) -> String { +#[pyo3(signature = (s, /, acronyms = None))] +fn to_title(s: &str, acronyms: Option<&PyDict>) -> String { + let word_fn = |buf: &mut String, s: &str| -> fmt::Result { + match get_acronym(s, acronyms) { + Some(acronym) => write!(buf, "{}", acronym), + None => fmt_title(buf, s), + } + }; + let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_title, " ").unwrap(); + transform_impl(s, &mut buf, word_fn, " ").unwrap(); buf } @@ -93,10 +122,17 @@ fn to_title(s: &str) -> String { #[pyfunction] fn to_upper(s: &str) -> String { let mut buf = String::with_capacity(s.len()); - transform(s, &mut buf, fmt_upper, " ").unwrap(); + transform_impl(s, &mut buf, fmt_upper, " ").unwrap(); buf } +fn get_acronym<'a>(s: &str, acronyms: Option<&'a PyDict>) -> Option<&'a str> { + acronyms + .as_ref() + .and_then(|d| d.get_item(s.to_lowercase())) + .and_then(|v| v.extract::<&str>().ok()) +} + /// A case conversion library with Unicode support, implemented in Rust. #[pymodule] fn cases(_py: Python, m: &PyModule) -> PyResult<()> { diff --git a/tests/test_cases.py b/tests/test_cases.py index 82665d5..23711d8 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -58,10 +58,34 @@ def test_to_camel(): assert cases.to_camel(s) == camel +def test_to_camel_with_acronyms(): + assert ( + cases.to_camel("xml_http_request", acronyms={"xml": "XML"}) == "xmlHttpRequest" + ) + assert ( + cases.to_camel("xml_http_request", acronyms={"http": "HTTP"}) + == "xmlHTTPRequest" + ) + + def test_to_pascal(): assert cases.to_pascal("test case") == "TestCase" +def test_to_pascal_with_acronyms(): + assert ( + cases.to_pascal("xml_http_request", acronyms={"xml": "XML"}) == "XMLHttpRequest" + ) + assert ( + cases.to_pascal("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) + == "XMLHTTPRequest" + ) + assert ( + cases.to_pascal("xml_http_request", acronyms={"xml": "XML", "http": "Http"}) + == "XMLHttpRequest" + ) + + def test_to_snake(): for s, snake, _ in TESTS: assert cases.to_snake(s) == snake @@ -83,6 +107,21 @@ def test_to_train(): assert cases.to_train("test case") == "Test-Case" +def test_to_train_with_acronyms(): + assert ( + cases.to_train("xml_http_request", acronyms={"xml": "XML"}) + == "XML-Http-Request" + ) + assert ( + cases.to_train("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) + == "XML-HTTP-Request" + ) + assert ( + cases.to_train("xml_http_request", acronyms={"xml": "XML", "http": "Http"}) + == "XML-Http-Request" + ) + + def test_to_lower(): assert cases.to_lower("Test-case") == "test case" @@ -91,5 +130,20 @@ def test_to_title(): assert cases.to_title("Test-case") == "Test Case" +def test_to_title_with_acronyms(): + assert ( + cases.to_title("xml_http_request", acronyms={"xml": "XML"}) + == "XML Http Request" + ) + assert ( + cases.to_title("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) + == "XML HTTP Request" + ) + assert ( + cases.to_title("xml_http_request", acronyms={"xml": "XML", "http": "Http"}) + == "XML Http Request" + ) + + def test_to_upper(): assert cases.to_upper("test case") == "TEST CASE" From 0f4c3962e87366d6622d38e0fbf77d09aaaf94d2 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 21:22:03 +0200 Subject: [PATCH 04/22] This is 0.0.0 --- Cargo.lock | 2 +- Cargo.toml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afa218c..fcbae74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "cases" -version = "0.1.0" +version = "0.0.0" dependencies = [ "pyo3", ] diff --git a/Cargo.toml b/Cargo.toml index 50b574c..808fed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,8 @@ [package] name = "cases" -version = "0.1.0" +version = "0.0.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] name = "cases" crate-type = ["cdylib"] From 1b85888bdf3579e9d4eeabeb79102871792c0627 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 21:59:16 +0200 Subject: [PATCH 05/22] Fix trailing non-lower and non-upper characters --- src/core.rs | 4 +++- tests/test_cases.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core.rs b/src/core.rs index cb1458f..0ef4e22 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,7 +1,7 @@ use std::fmt; use std::fmt::Write; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq)] enum State { Unknown, Delims, @@ -73,6 +73,8 @@ where state = State::Lower; } else if is_upper { state = State::Upper; + } else if state == State::Delims { + state = State::Unknown; } } diff --git a/tests/test_cases.py b/tests/test_cases.py index 23711d8..f6671bf 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -37,6 +37,8 @@ ("XΣXΣ baffle", "xσxσ_baffle", "xσxσBaffle"), ("XMLHttpRequest", "xml_http_request", "xmlHttpRequest"), ("FIELD_NAME11", "field_name11", "fieldName11"), + ("FIELD_NAME_11", "field_name_11", "fieldName11"), + ("FIELD_NAME_1", "field_name_1", "fieldName1"), ("99BOTTLES", "99bottles", "99bottles"), ("FieldNamE11", "field_nam_e11", "fieldNamE11"), ("abc123def456", "abc123def456", "abc123def456"), From 7ca1bde4087d0d66cebd82e7920ee34613f1746c Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 22:22:03 +0200 Subject: [PATCH 06/22] Build on GitHub Actions --- .github/workflows/build.yaml | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..0596363 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,107 @@ +name: build + +on: [push, 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.11' + - 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.11' + 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.11' + - 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_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing * From aa60262504f9b9629069cd374bcd2f41001e9572 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 22:27:46 +0200 Subject: [PATCH 07/22] Update README --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 05538ac..08e643c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,62 @@ # pycases -A case conversion library with Unicode support. +A case conversion library for Python with Unicode support. + + +The currently supported cases are: + +| Function | Output | +| :---------------------------- | :--------------------- | +| `cases.to_camel(s)` | `camelCase` | +| `cases.to_pascal(s)` | `PascalCase` | +| `cases.to_snake(s)` | `snake_case` | +| `cases.to_screaming_snake(s)` | `SCREAMING_SNAKE_CASE` | +| `cases.to_kebab(s)` | `kebab-case` | +| `cases.to_screaming_kebab(s)` | `SCREAMING-KEBAB-CASE` | +| `cases.to_train(s)` | `Train-Case` | +| `cases.to_lower(s)` | `lower case` | +| `cases.to_title(s)` | `Title Case` | +| `cases.to_upper(s)` | `UPPER CASE` | + + +## Getting started + +Install using + +```sh +pip install pycases +``` + +Now convert a string using the relevant function. + +```python +import cases + +cases.to_snake("XMLHttpRequest") # returns "xml_http_request" +``` + +## Details + +Word boundaries are defined as follows: + +- A set of consecutive Unicode non-letter/number + e.g. 'foo _bar' is two words (foo and bar) + +- A transition from a lowercase letter to an uppercase letter + e.g. fooBar is two words (foo and Bar) + + - The second last uppercase letter in a word with multiple uppercase letters + e.g. FOOBar is two words (FOO and Bar) + +Some functions accept an optional `acronyms` argument, which is a mapping of +lowercase words to their output. For example: + +```python +>>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML"}) +'XMLHttpRequest' +>>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) +'XMLHTTPRequest' +``` ## License From c8eb6fd5b9d0b8caba27e3f5de03371a8f8d81e8 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Wed, 13 Sep 2023 22:28:25 +0200 Subject: [PATCH 08/22] This is 0.1.0 --- Cargo.toml | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 808fed6..c28315d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "cases" version = "0.0.0" edition = "2021" +publish = false [lib] name = "cases" diff --git a/pyproject.toml b/pyproject.toml index 679636f..67288ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ features = ["pyo3/extension-module"] [project] name = "pycases" -version = "0.0.0" +version = "0.1.0" description = "A case conversion library with Unicode support" requires-python = ">=3.7" license = { text = "MIT" } From ed9b526230e9ce7cd6a25cbc604742414282080e Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Fri, 15 Sep 2023 08:54:58 +0200 Subject: [PATCH 09/22] Add `transform::to_string()` function --- src/lib.rs | 44 ++++++++++------------------------- src/{core.rs => transform.rs} | 16 +++++++++++-- 2 files changed, 26 insertions(+), 34 deletions(-) rename src/{core.rs => transform.rs} (83%) diff --git a/src/lib.rs b/src/lib.rs index c441a45..06ad9e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -mod core; +mod transform; use std::fmt; use std::fmt::Write; @@ -6,7 +6,7 @@ use std::fmt::Write; use pyo3::prelude::*; use pyo3::types::PyDict; -use crate::core::{fmt_lower, fmt_title, fmt_upper, transform_impl}; +use crate::transform::{fmt_lower, fmt_title, fmt_upper}; /// Convert a string to 'camelCase'. #[pyfunction] @@ -25,9 +25,7 @@ fn to_camel(s: &str, acronyms: Option<&PyDict>) -> String { } }; - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, word_fn, "").unwrap(); - buf + transform::to_string(s, word_fn, "") } /// Convert a string to 'PascalCase'. @@ -41,41 +39,31 @@ fn to_pascal(s: &str, acronyms: Option<&PyDict>) -> String { } }; - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, word_fn, "").unwrap(); - buf + transform::to_string(s, word_fn, "") } /// Convert a string to 'snake_case'. #[pyfunction] fn to_snake(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, fmt_lower, "_").unwrap(); - buf + transform::to_string(s, fmt_lower, "_") } /// Convert a string to 'SCREAMING_SNAKE_CASE'. #[pyfunction] fn to_screaming_snake(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, fmt_upper, "_").unwrap(); - buf + transform::to_string(s, fmt_upper, "_") } /// Convert a string to 'kebab-case'. #[pyfunction] fn to_kebab(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, fmt_lower, "-").unwrap(); - buf + transform::to_string(s, fmt_lower, "-") } /// Convert a string to 'SCREAMING-KEBAB-CASE'. #[pyfunction] fn to_screaming_kebab(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, fmt_upper, "-").unwrap(); - buf + transform::to_string(s, fmt_upper, "-") } /// Convert a string to 'Train-Case'. @@ -89,17 +77,13 @@ fn to_train(s: &str, acronyms: Option<&PyDict>) -> String { } }; - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, word_fn, "-").unwrap(); - buf + transform::to_string(s, word_fn, "-") } /// Convert a string to 'lower case'. #[pyfunction] fn to_lower(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, fmt_lower, " ").unwrap(); - buf + transform::to_string(s, fmt_lower, " ") } /// Convert a string to 'Title Case'. @@ -113,17 +97,13 @@ fn to_title(s: &str, acronyms: Option<&PyDict>) -> String { } }; - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, word_fn, " ").unwrap(); - buf + transform::to_string(s, word_fn, " ") } /// Convert a string to 'UPPER CASE'. #[pyfunction] fn to_upper(s: &str) -> String { - let mut buf = String::with_capacity(s.len()); - transform_impl(s, &mut buf, fmt_upper, " ").unwrap(); - buf + transform::to_string(s, fmt_upper, " ") } fn get_acronym<'a>(s: &str, acronyms: Option<&'a PyDict>) -> Option<&'a str> { diff --git a/src/core.rs b/src/transform.rs similarity index 83% rename from src/core.rs rename to src/transform.rs index 0ef4e22..8563e49 100644 --- a/src/core.rs +++ b/src/transform.rs @@ -9,8 +9,20 @@ enum State { Upper, } -// Transform reconstructs the string using the given functions. -pub fn transform_impl(s: &str, buf: &mut B, mut word_fn: W, delim: &str) -> fmt::Result +/// Transforms reconstructs the string into a new string using the given +/// functions. +pub fn to_string(s: &str, word_fn: F, delim: &str) -> String +where + F: FnMut(&mut String, &str) -> fmt::Result, +{ + let mut buf = String::with_capacity(s.len()); + transform(s, &mut buf, word_fn, delim).unwrap(); + buf +} + +/// Transform reconstructs the string into the given buffer using the given +/// functions. +pub fn transform(s: &str, buf: &mut B, mut word_fn: W, delim: &str) -> fmt::Result where B: Write, W: FnMut(&mut B, &str) -> fmt::Result, From d37ddb67fca2802fc22e38afdf8608838a1e126a Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Fri, 15 Sep 2023 08:58:23 +0200 Subject: [PATCH 10/22] Slight simplication to `transform` function --- src/transform.rs | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/transform.rs b/src/transform.rs index 8563e49..43424e0 100644 --- a/src/transform.rs +++ b/src/transform.rs @@ -36,16 +36,14 @@ where // the current state of the word boundary machine let mut state = State::Unknown; - let mut write = |w0: usize, w1: Option| -> fmt::Result { - if let Some(w1) = w1 { - if w1 - w0 > 0 { - if first { - first = false; - } else { - buf.write_str(delim)?; - } - word_fn(buf, &s[w0..w1])?; + let mut write = |w0: usize, w1: usize| -> fmt::Result { + if w1 - w0 > 0 { + if first { + first = false; + } else { + buf.write_str(delim)?; } + word_fn(buf, &s[w0..w1])?; } Ok(()) }; @@ -64,18 +62,20 @@ where match state { State::Delims => { - write(w0, w1)?; + if let Some(w1) = w1 { + write(w0, w1)?; + } w0 = i; w1 = None; } State::Lower if is_upper => { - write(w0, Some(i))?; + write(w0, i)?; w0 = i; } State::Upper if is_upper && matches!(iter.peek(), Some((_, c2)) if c2.is_lowercase()) => { - write(w0, Some(i))?; + write(w0, i)?; w0 = i; } _ => {} @@ -91,8 +91,12 @@ where } match state { - State::Delims => write(w0, w1)?, - _ => write(w0, Some(s.len()))?, + State::Delims => { + if let Some(w1) = w1 { + write(w0, w1)?; + } + } + _ => write(w0, s.len())?, } Ok(()) From d6cbf427eca01a3a25dcb1955c0717de52bd9fcf Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Mon, 18 Sep 2023 14:06:32 +0200 Subject: [PATCH 11/22] Add project URLs to pyproject.toml and README badges --- README.md | 5 +++++ pyproject.toml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 08e643c..eb559b1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # pycases +[![PyPI version](https://badgers.space/pypi/version/pycases)](https://pypi.org/project/pycases) +[![License](https://badgers.space/github/license/rossmacarthur/pycases)](https://github.com/rossmacarthur/pycases#license) +[![Build Status](https://badgers.space/github/checks/rossmacarthur/pycases/trunk?label=build)](https://github.com/rossmacarthur/pycases/actions/workflows/build.yaml) + + A case conversion library for Python with Unicode support. diff --git a/pyproject.toml b/pyproject.toml index 67288ec..468d76f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] + +[project.urls] +Homepage = "https://github.com/rossmacarthur/pycases" +Repository = "https://github.com/rossmacarthur/pycases" From 18536e60464b12d25aed05937c656805d8e75837 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Mon, 18 Sep 2023 14:25:58 +0200 Subject: [PATCH 12/22] Add some basic benchmarks against other impls Spoiler: this project is the fastest --- benches/test_bench.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 benches/test_bench.py diff --git a/benches/test_bench.py b/benches/test_bench.py new file mode 100644 index 0000000..c1f9b79 --- /dev/null +++ b/benches/test_bench.py @@ -0,0 +1,42 @@ +import re +import cases +import inflection +import stringcase +from pytest_benchmark.fixture import BenchmarkFixture + + +LEN = 100 + + +def test_bench_to_snake_pure_python(benchmark: BenchmarkFixture): + def camel_to_snake(s): + return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_") + + s = "thisIsACamelCaseString" * LEN + result = benchmark(camel_to_snake, s) + assert result == "this_is_a_camel_case_string" * LEN + + +def test_bench_to_snake_regex(benchmark: BenchmarkFixture): + s = "thisIsACamelCaseString" * LEN + pattern = re.compile(r"(? Date: Mon, 18 Sep 2023 15:54:16 +0200 Subject: [PATCH 13/22] Tidy up files included in sdist --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index c28315d..b49f8e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "cases" version = "0.0.0" edition = "2021" publish = false +include = ["/src/**/*", "/*.py", "/*.pyi", "/LICENSE", "/README.md"] [lib] name = "cases" From 8c05ef57a69a20eee8d5ccb79e30a23c89a620ab Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Mon, 18 Sep 2023 15:55:09 +0200 Subject: [PATCH 14/22] This is 0.1.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 468d76f..91692e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ features = ["pyo3/extension-module"] [project] name = "pycases" -version = "0.1.0" +version = "0.1.1" description = "A case conversion library with Unicode support" requires-python = ">=3.7" license = { text = "MIT" } From 2defb78ea7bbdef5a1e161d5233130bd8e24db99 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Tue, 24 Oct 2023 18:07:17 +0200 Subject: [PATCH 15/22] Update documentation and test examples --- README.md | 28 +++--- cases.pyi | 85 ------------------ cases/__init__.pyi | 209 ++++++++++++++++++++++++++++++++++++++++++++ cases/py.typed | 0 pyproject.toml | 1 + tests/test_cases.py | 19 ++++ 6 files changed, 247 insertions(+), 95 deletions(-) delete mode 100644 cases.pyi create mode 100644 cases/__init__.pyi create mode 100644 cases/py.typed diff --git a/README.md b/README.md index eb559b1..4ea6618 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,8 @@ [![License](https://badgers.space/github/license/rossmacarthur/pycases)](https://github.com/rossmacarthur/pycases#license) [![Build Status](https://badgers.space/github/checks/rossmacarthur/pycases/trunk?label=build)](https://github.com/rossmacarthur/pycases/actions/workflows/build.yaml) - A case conversion library for Python with Unicode support. - The currently supported cases are: | Function | Output | @@ -42,19 +40,29 @@ cases.to_snake("XMLHttpRequest") # returns "xml_http_request" ## Details +Each of the provided functions using the same underlying implementation which +does the following: +- Divide the input string into words +- Convert each word as required +- Join the words back together optionally with a separator + Word boundaries are defined as follows: -- A set of consecutive Unicode non-letter/number - e.g. 'foo _bar' is two words (foo and bar) +- A set of consecutive Unicode non-letter and non-number characters. + + For example: 'foo _bar' is two words (foo and bar) + +- A transition from a lowercase letter to an uppercase letter. + + For example: fooBar is two words (foo and Bar) -- A transition from a lowercase letter to an uppercase letter - e.g. fooBar is two words (foo and Bar) +- A transition from multiple uppercase letters to a single uppercase letter + followed by lowercase letters. - - The second last uppercase letter in a word with multiple uppercase letters - e.g. FOOBar is two words (FOO and Bar) + For example: FOOBar is two words (FOO and Bar) -Some functions accept an optional `acronyms` argument, which is a mapping of -lowercase words to their output. For example: +Functions where the transform is "title" accept an optional `acronyms` argument, +which is a mapping of lowercase words to their output. For example: ```python >>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML"}) diff --git a/cases.pyi b/cases.pyi deleted file mode 100644 index b6c8913..0000000 --- a/cases.pyi +++ /dev/null @@ -1,85 +0,0 @@ -""" -A case conversion library with Unicode support, implemented in Rust. - -Word boundaries are defined as follows: - -- A set of consecutive Unicode non-letter/number - e.g. 'foo _bar' is two words (foo and bar) - -- A transition from a lowercase letter to an uppercase letter - e.g. fooBar is two words (foo and Bar) - - - The second last uppercase letter in a word with multiple uppercase letters - e.g. FOOBar is two words (FOO and Bar) - -Some functions accept an optional `acronyms` argument, which is a mapping of -lowercase words to their output. For example: - - >>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML"}) - 'XMLHttpRequest' - >>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) - 'XMLHTTPRequest' - -""" - -from typing import Optional - -def to_camel(s: str, acronyms: Optional[dict[str, str]] = None) -> str: - """ - Convert a string to 'camelCase'. - """ - ... - -def to_pascal(s: str, acronyms: Optional[dict[str, str]] = None) -> str: - """ - Convert a string to 'PascalCase'. - """ - ... - -def to_snake(s: str) -> str: - """ - Convert a string to 'camelCase'. - """ - ... - -def to_screaming_snake(s: str) -> str: - """ - Convert a string to 'SCREAMING_SNAKE_CASE'. - """ - ... - -def to_kebab(s: str) -> str: - """ - Convert a string to 'kebab-case'. - """ - ... - -def to_screaming_kebab(s: str) -> str: - """ - Convert a string to 'SCREAMING-KEBAB-CASE'. - """ - ... - -def to_train(s: str, acronyms: Optional[dict[str, str]] = None) -> str: - """ - Convert a string to 'Train-Case'. - """ - ... - -def to_lower(s: str) -> str: - """ - Convert a string to 'lower case'. - """ - ... - -def to_title(s: str, acronyms: Optional[dict[str, str]] = None) -> str: - """ - Convert a string to 'Title Case'. - """ - ... - -def to_upper(s: str) -> str: - """ - Convert a string to 'UPPER CASE'. - """ - ... diff --git a/cases/__init__.pyi b/cases/__init__.pyi new file mode 100644 index 0000000..fb69224 --- /dev/null +++ b/cases/__init__.pyi @@ -0,0 +1,209 @@ +""" +A case conversion library with Unicode support, implemented in Rust. + +Each of the provided functions using the same underlying implementation which +does the following: +- Divide the input string into words +- Convert each word as required +- Join the words back together optionally with a separator + +Word boundaries are defined as follows: + +- A set of consecutive Unicode non-letter and non-number characters. + + For example: 'foo _bar' is two words (foo and bar) + +- A transition from a lowercase letter to an uppercase letter. + + For example: fooBar is two words (foo and Bar) + +- A transition from multiple uppercase letters to a single uppercase letter + followed by lowercase letters. + + For example: FOOBar is two words (FOO and Bar) + +""" + +from typing import Optional + +def to_camel(s: str, acronyms: Optional[dict[str, str]] = None) -> str: + """ + Convert a string to 'camelCase'. + + The first word will be converted to lowercase and subsequent words to title + case. See module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_camel("foo_bar") + 'fooBar' + + The `acronyms` argument is a mapping of lowercase words to an override + value. This value will be used instead of the camel case conversion. + + For example: + + >>> cases.to_camel("xml http request", acronyms={"http": "HTTP"}) + 'xmlHTTPRequest' + + """ + ... + +def to_pascal(s: str, acronyms: Optional[dict[str, str]] = None) -> str: + """ + Convert a string to 'PascalCase'. + + Each word will be converted to title case. See module documentation for how + word boundaries are defined. + + For example: + + >>> cases.to_pascal("foo_bar") + 'FooBar' + + The `acronyms` argument is a mapping of lowercase words to an override + value. This value will be used instead of the pascal case conversion. + + For example: + + >>> cases.to_pascal("xml http request", acronyms={"http": "HTTP"}) + 'XmlHTTPRequest' + + """ + ... + +def to_snake(s: str) -> str: + """ + Convert a string to 'snake_case'. + + Each word will be converted to lower case and separated with an underscore. + See module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_snake("fooBar") + 'foo_bar' + + """ + ... + +def to_screaming_snake(s: str) -> str: + """ + Convert a string to 'SCREAMING_SNAKE_CASE'. + + Each word will be converted to upper case and separated with an underscore. + See module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_screaming_snake("fooBar") + 'FOO_BAR' + + """ + ... + +def to_kebab(s: str) -> str: + """ + Convert a string to 'kebab-case'. + + Each word will be converted to lower case and separated with a hyphen. See + module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_kebab("fooBar") + 'foo-bar' + + """ + ... + +def to_screaming_kebab(s: str) -> str: + """ + Convert a string to 'SCREAMING-KEBAB-CASE'. + + Each word will be converted to upper case and separated with a hyphen. See + module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_screaming_kebab("fooBar") + 'FOO-BAR' + + """ + ... + +def to_train(s: str, acronyms: Optional[dict[str, str]] = None) -> str: + """ + Convert a string to 'Train-Case'. + + Each word will be converted to title case and separated with a hyphen. See + module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_train("fooBar") + 'Foo-Bar' + + The `acronyms` argument is a mapping of lowercase words to an override + value. This value will be used instead of the train case conversion. + + For example: + + >>> cases.to_train("xml http request", acronyms={"http": "HTTP"}) + 'Xml-HTTP-Request' + + """ + ... + +def to_lower(s: str) -> str: + """ + Convert a string to 'lower case'. + + Each word will be converted to lower case and separated with a space. See + module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_lower("FooBar") + 'foo bar' + + """ + ... + +def to_title(s: str, acronyms: Optional[dict[str, str]] = None) -> str: + """ + Convert a string to 'Title Case'. + + Each word will be converted to title case and separated with a space. See + module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_title("foo_bar") + 'Foo Bar' + + The `acronyms` argument is a mapping of lowercase words to an override + value. This value will be used instead of the title case conversion. + + For example: + + >>> cases.to_title("xml_http_request", acronyms={"http": "HTTP"}) + 'Xml HTTP Request' + + """ + ... + +def to_upper(s: str) -> str: + """ + Convert a string to 'UPPER CASE'. + + Each word will be converted to upper case and separated with a space. See + module documentation for how word boundaries are defined. + + For example: + + >>> cases.to_upper("fooBar") + 'FOO BAR' + + """ + ... diff --git a/cases/py.typed b/cases/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 91692e2..6e94497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] [project.urls] diff --git a/tests/test_cases.py b/tests/test_cases.py index f6671bf..f04890f 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -1,4 +1,8 @@ +import re +import os + import cases +import pytest TESTS = [ ("", "", ""), @@ -149,3 +153,18 @@ def test_to_title_with_acronyms(): def test_to_upper(): assert cases.to_upper("test case") == "TEST CASE" + + +def examples() -> list[tuple[str, str]]: + pyi_file = os.path.join(os.path.dirname(__file__), "..", "cases", "__init__.pyi") + with open(pyi_file) as f: + contents = f.read() + examples = re.findall(r"^\s*>>> (.*)\n\s*(.*)$", contents, re.MULTILINE) + assert len(examples) == 15 + return list(examples) + + +@pytest.mark.parametrize("case", examples()) +def test_doc_example(case: tuple[str, str]): + code, expected = case + exec(f"""result = {code}\nassert result == {expected}""") From 5a1f2e2b352121e0ae9bbe06b3af61349bfaef68 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Tue, 24 Oct 2023 18:36:36 +0200 Subject: [PATCH 16/22] Update benchmarks, add to README --- README.md | 26 ++++++++++++++++++++++++-- benches/test_bench.py | 30 +++++++++++++++--------------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4ea6618..3e96ec0 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,16 @@ [![License](https://badgers.space/github/license/rossmacarthur/pycases)](https://github.com/rossmacarthur/pycases#license) [![Build Status](https://badgers.space/github/checks/rossmacarthur/pycases/trunk?label=build)](https://github.com/rossmacarthur/pycases/actions/workflows/build.yaml) -A case conversion library for Python with Unicode support. +A case conversion library for Python. -The currently supported cases are: +## Features + +- Automatic case detection, no need to specify the input case +- Extremely fast, written in Rust ✨ +- Support for Unicode characters +- Support for providing acronyms in title case + +**Supported cases** | Function | Output | | :---------------------------- | :--------------------- | @@ -71,6 +78,21 @@ which is a mapping of lowercase words to their output. For example: 'XMLHTTPRequest' ``` +## Benchmarks + +A simple benchmark against various other libraries is provided in +[./benches](./benches). The following table shows the results when run on my +Macbook M2 Max. + +| Library | Min (µs) | Max (µs) | Mean (µs) | +| --------------- | ---------: | ---------: | ---------: | +| cases | 21.3750 | 49.6670 | 22.1288 | +| pure python | 62.8750 | 186.9580 | 66.2344 | +| regex | 80.8330 | 201.2500 | 87.0549 | +| stringcase | 101.8340 | 204.9590 | 108.6977 | +| inflection | 230.2920 | 581.4580 | 253.9194 | +| case-conversion | 1,431.7920 | 1,745.7080 | 1,506.2268 | + ## License This project is licensed under the terms of the MIT license. See diff --git a/benches/test_bench.py b/benches/test_bench.py index c1f9b79..fbde343 100644 --- a/benches/test_bench.py +++ b/benches/test_bench.py @@ -1,42 +1,42 @@ import re import cases import inflection +import case_conversion import stringcase from pytest_benchmark.fixture import BenchmarkFixture LEN = 100 +INPUT = "thisIsACamelCaseString" * LEN +EXPECT = "this_is_a_camel_case_string" * LEN def test_bench_to_snake_pure_python(benchmark: BenchmarkFixture): def camel_to_snake(s): return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_") - s = "thisIsACamelCaseString" * LEN - result = benchmark(camel_to_snake, s) - assert result == "this_is_a_camel_case_string" * LEN + assert benchmark(camel_to_snake, INPUT) == EXPECT def test_bench_to_snake_regex(benchmark: BenchmarkFixture): - s = "thisIsACamelCaseString" * LEN pattern = re.compile(r"(? Date: Wed, 25 Oct 2023 08:08:23 +0200 Subject: [PATCH 17/22] This is 0.1.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e94497..1ce2f58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ features = ["pyo3/extension-module"] [project] name = "pycases" -version = "0.1.1" +version = "0.1.2" description = "A case conversion library with Unicode support" requires-python = ">=3.7" license = { text = "MIT" } From 81ca55c4ef3372edfad926fb4247561b479e29e3 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Mon, 30 Oct 2023 17:15:19 +0200 Subject: [PATCH 18/22] Fix missing cases `__init__.py` --- cases/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 cases/__init__.py diff --git a/cases/__init__.py b/cases/__init__.py new file mode 100644 index 0000000..12e0323 --- /dev/null +++ b/cases/__init__.py @@ -0,0 +1,4 @@ +from .cases import * + +__doc__ = cases.__doc__ +__all__ = cases.__all__ From da54ffee3046918551e2b5273c3a6d626c4b4bba Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Mon, 30 Oct 2023 18:09:41 +0200 Subject: [PATCH 19/22] Test on GitHub Actions --- .github/workflows/build.yaml | 25 +++++++++++++++++++++++++ dev/requirements.in | 7 +++++++ dev/requirements.txt | 30 ++++++++++++++++++++++++++++++ tests/test_cases.py | 2 +- 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 dev/requirements.in create mode 100644 dev/requirements.txt diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0596363..29216ef 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,8 +6,30 @@ permissions: contents: read jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + pip install --upgrade pip wheel setuptools + pip install -r dev/requirements.txt + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Install wheel + run: pip install pycases --find-links dist --force-reinstall + - name: Test + run: pytest --benchmark-disable + linux: runs-on: ubuntu-latest + needs: test strategy: matrix: target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] @@ -31,6 +53,7 @@ jobs: windows: runs-on: windows-latest + needs: test strategy: matrix: target: [x64, x86] @@ -54,6 +77,7 @@ jobs: macos: runs-on: macos-latest + needs: test strategy: matrix: target: [x86_64, aarch64] @@ -76,6 +100,7 @@ jobs: sdist: runs-on: ubuntu-latest + needs: test steps: - uses: actions/checkout@v3 - name: Build sdist diff --git a/dev/requirements.in b/dev/requirements.in new file mode 100644 index 0000000..89ccad3 --- /dev/null +++ b/dev/requirements.in @@ -0,0 +1,7 @@ +ruff +pytest +pytest-benchmark + +case_conversion +inflection +stringcase diff --git a/dev/requirements.txt b/dev/requirements.txt new file mode 100644 index 0000000..f0eba7c --- /dev/null +++ b/dev/requirements.txt @@ -0,0 +1,30 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --strip-extras dev/requirements.in +# +case-conversion==2.1.0 + # via -r dev/requirements.in +inflection==0.5.1 + # via -r dev/requirements.in +iniconfig==2.0.0 + # via pytest +packaging==23.2 + # via pytest +pluggy==1.3.0 + # via pytest +py-cpuinfo==9.0.0 + # via pytest-benchmark +pytest==7.4.3 + # via + # -r dev/requirements.in + # pytest-benchmark +pytest-benchmark==4.0.0 + # via -r dev/requirements.in +regex==2023.10.3 + # via case-conversion +ruff==0.1.3 + # via -r dev/requirements.in +stringcase==1.2.0 + # via -r dev/requirements.in diff --git a/tests/test_cases.py b/tests/test_cases.py index f04890f..04cb93c 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -160,7 +160,7 @@ def examples() -> list[tuple[str, str]]: with open(pyi_file) as f: contents = f.read() examples = re.findall(r"^\s*>>> (.*)\n\s*(.*)$", contents, re.MULTILINE) - assert len(examples) == 15 + assert len(examples) == 14 return list(examples) From b045f397b8ddf7b5105f7647b13c31cf1442a7a9 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Mon, 30 Oct 2023 18:14:33 +0200 Subject: [PATCH 20/22] This is 0.1.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1ce2f58..1abfdf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ features = ["pyo3/extension-module"] [project] name = "pycases" -version = "0.1.2" +version = "0.1.3" description = "A case conversion library with Unicode support" requires-python = ">=3.7" license = { text = "MIT" } From ea94dad3c8e5b4d24e89bb49b943f884fe447a5f Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Sat, 6 Jul 2024 10:07:04 +0200 Subject: [PATCH 21/22] Add `pydantic.alias_generators` and `pyheck` to benches --- README.md | 18 +++++++++-------- benches/test_bench.py | 46 ++++++++++++++++++++++++++++++------------- dev/requirements.in | 3 +++ dev/requirements.txt | 22 +++++++++++++++------ 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3e96ec0..0c2ab66 100644 --- a/README.md +++ b/README.md @@ -84,14 +84,16 @@ A simple benchmark against various other libraries is provided in [./benches](./benches). The following table shows the results when run on my Macbook M2 Max. -| Library | Min (µs) | Max (µs) | Mean (µs) | -| --------------- | ---------: | ---------: | ---------: | -| cases | 21.3750 | 49.6670 | 22.1288 | -| pure python | 62.8750 | 186.9580 | 66.2344 | -| regex | 80.8330 | 201.2500 | 87.0549 | -| stringcase | 101.8340 | 204.9590 | 108.6977 | -| inflection | 230.2920 | 581.4580 | 253.9194 | -| case-conversion | 1,431.7920 | 1,745.7080 | 1,506.2268 | +| Library | Min (µs) | Max (µs) | Mean (µs) | +| :------------------------ | --------: | --------: | ------------: | +| cases | 26.666 | 176.834 | **30.909** | +| pyheck | 51.000 | 131.416 | **53.565** | +| pure python | 63.583 | 108.125 | **65.075** | +| re | 81.916 | 171.000 | **87.856** | +| stringcase | 99.250 | 222.292 | **102.197** | +| pydantic.alias_generators | 182.000 | 304.458 | **189.063** | +| inflection | 229.750 | 360.792 | **239.153** | +| caseconversion | 1,430.042 | 1,838.375 | **1,559.019** | ## License diff --git a/benches/test_bench.py b/benches/test_bench.py index fbde343..2dd9475 100644 --- a/benches/test_bench.py +++ b/benches/test_bench.py @@ -1,8 +1,3 @@ -import re -import cases -import inflection -import case_conversion -import stringcase from pytest_benchmark.fixture import BenchmarkFixture @@ -12,31 +7,54 @@ def test_bench_to_snake_pure_python(benchmark: BenchmarkFixture): - def camel_to_snake(s): + def to_snake(s: str) -> str: return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_") - assert benchmark(camel_to_snake, INPUT) == EXPECT + assert benchmark(to_snake, INPUT) == EXPECT -def test_bench_to_snake_regex(benchmark: BenchmarkFixture): +def test_bench_to_snake_python_re(benchmark: BenchmarkFixture): + import re + pattern = re.compile(r"(? str: return pattern.sub("_", s).lower() - assert benchmark(camel_to_snake, INPUT) == EXPECT + assert benchmark(to_snake, INPUT) == EXPECT + def test_bench_to_snake_cases(benchmark: BenchmarkFixture): - assert benchmark(cases.to_snake, INPUT) == EXPECT + from cases import to_snake + + assert benchmark(to_snake, INPUT) == EXPECT def test_bench_to_snake_caseconversion(benchmark: BenchmarkFixture): - assert benchmark(case_conversion.snakecase, INPUT) == EXPECT + from case_conversion import snakecase as to_snake + + assert benchmark(to_snake, INPUT) == EXPECT def test_bench_to_snake_inflection(benchmark: BenchmarkFixture): - assert benchmark(inflection.underscore, INPUT) == EXPECT + from inflection import underscore as to_snake + + assert benchmark(to_snake, INPUT) == EXPECT + + +def test_bench_to_snake_pydantic(benchmark: BenchmarkFixture): + from pydantic.alias_generators import to_snake + + assert benchmark(to_snake, INPUT) == EXPECT + + +def test_bench_to_snake_pyheck(benchmark: BenchmarkFixture): + from pyheck import snake as to_snake + + assert benchmark(to_snake, INPUT) == EXPECT def test_bench_to_snake_stringcase(benchmark: BenchmarkFixture): - assert benchmark(stringcase.snakecase, INPUT) == EXPECT + from stringcase import snakecase as to_snake + + assert benchmark(to_snake, INPUT) == EXPECT diff --git a/dev/requirements.in b/dev/requirements.in index 89ccad3..2b11501 100644 --- a/dev/requirements.in +++ b/dev/requirements.in @@ -1,7 +1,10 @@ +maturin ruff pytest pytest-benchmark case_conversion inflection +pydantic +pyheck stringcase diff --git a/dev/requirements.txt b/dev/requirements.txt index f0eba7c..f3a31ba 100644 --- a/dev/requirements.txt +++ b/dev/requirements.txt @@ -1,21 +1,27 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --strip-extras dev/requirements.in -# +# This file was autogenerated by uv via the following command: +# uv pip compile -o dev/requirements.txt dev/requirements.in +annotated-types==0.7.0 + # via pydantic case-conversion==2.1.0 # via -r dev/requirements.in inflection==0.5.1 # via -r dev/requirements.in iniconfig==2.0.0 # via pytest +maturin==1.6.0 + # via -r dev/requirements.in packaging==23.2 # via pytest pluggy==1.3.0 # via pytest py-cpuinfo==9.0.0 # via pytest-benchmark +pydantic==2.8.2 + # via -r dev/requirements.in +pydantic-core==2.20.1 + # via pydantic +pyheck==0.1.5 + # via -r dev/requirements.in pytest==7.4.3 # via # -r dev/requirements.in @@ -28,3 +34,7 @@ ruff==0.1.3 # via -r dev/requirements.in stringcase==1.2.0 # via -r dev/requirements.in +typing-extensions==4.12.2 + # via + # pydantic + # pydantic-core From ecb0018522b56021b2090b0ae6b46a3d339950f8 Mon Sep 17 00:00:00 2001 From: Ross MacArthur Date: Sat, 6 Jul 2024 16:25:07 +0200 Subject: [PATCH 22/22] python: Move into ./python directory --- .github/workflows/{build.yaml => python.yaml} | 11 ++++++++++- .gitignore => python/.gitignore | 0 Cargo.lock => python/Cargo.lock | 0 Cargo.toml => python/Cargo.toml | 0 README.md => python/README.md | 2 +- {benches => python/benches}/test_bench.py | 0 {cases => python/cases}/__init__.py | 0 {cases => python/cases}/__init__.pyi | 0 {cases => python/cases}/py.typed | 0 {dev => python/dev}/requirements.in | 0 {dev => python/dev}/requirements.txt | 0 pyproject.toml => python/pyproject.toml | 0 {src => python/src}/lib.rs | 0 {src => python/src}/transform.rs | 0 {tests => python/tests}/test_cases.py | 0 15 files changed, 11 insertions(+), 2 deletions(-) rename .github/workflows/{build.yaml => python.yaml} (93%) rename .gitignore => python/.gitignore (100%) rename Cargo.lock => python/Cargo.lock (100%) rename Cargo.toml => python/Cargo.toml (100%) rename README.md => python/README.md (98%) rename {benches => python/benches}/test_bench.py (100%) rename {cases => python/cases}/__init__.py (100%) rename {cases => python/cases}/__init__.pyi (100%) rename {cases => python/cases}/py.typed (100%) rename {dev => python/dev}/requirements.in (100%) rename {dev => python/dev}/requirements.txt (100%) rename pyproject.toml => python/pyproject.toml (100%) rename {src => python/src}/lib.rs (100%) rename {src => python/src}/transform.rs (100%) rename {tests => python/tests}/test_cases.py (100%) diff --git a/.github/workflows/build.yaml b/.github/workflows/python.yaml similarity index 93% rename from .github/workflows/build.yaml rename to .github/workflows/python.yaml index 29216ef..20f28bf 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/python.yaml @@ -1,10 +1,14 @@ -name: build +name: python on: [push, pull_request, workflow_dispatch] permissions: contents: read +defaults: + run: + working-directory: python + jobs: test: runs-on: ubuntu-latest @@ -20,6 +24,7 @@ jobs: - name: Build wheel uses: PyO3/maturin-action@v1 with: + working-directory: python args: --release --out dist --find-interpreter sccache: 'true' - name: Install wheel @@ -41,6 +46,7 @@ jobs: - name: Build wheels uses: PyO3/maturin-action@v1 with: + working-directory: python target: ${{ matrix.target }} args: --release --out dist --find-interpreter sccache: 'true' @@ -66,6 +72,7 @@ jobs: - name: Build wheels uses: PyO3/maturin-action@v1 with: + working-directory: python target: ${{ matrix.target }} args: --release --out dist --find-interpreter sccache: 'true' @@ -89,6 +96,7 @@ jobs: - name: Build wheels uses: PyO3/maturin-action@v1 with: + working-directory: python target: ${{ matrix.target }} args: --release --out dist --find-interpreter sccache: 'true' @@ -106,6 +114,7 @@ jobs: - name: Build sdist uses: PyO3/maturin-action@v1 with: + working-directory: python command: sdist args: --out dist - name: Upload sdist diff --git a/.gitignore b/python/.gitignore similarity index 100% rename from .gitignore rename to python/.gitignore diff --git a/Cargo.lock b/python/Cargo.lock similarity index 100% rename from Cargo.lock rename to python/Cargo.lock diff --git a/Cargo.toml b/python/Cargo.toml similarity index 100% rename from Cargo.toml rename to python/Cargo.toml diff --git a/README.md b/python/README.md similarity index 98% rename from README.md rename to python/README.md index 0c2ab66..0cffd49 100644 --- a/README.md +++ b/python/README.md @@ -2,7 +2,7 @@ [![PyPI version](https://badgers.space/pypi/version/pycases)](https://pypi.org/project/pycases) [![License](https://badgers.space/github/license/rossmacarthur/pycases)](https://github.com/rossmacarthur/pycases#license) -[![Build Status](https://badgers.space/github/checks/rossmacarthur/pycases/trunk?label=build)](https://github.com/rossmacarthur/pycases/actions/workflows/build.yaml) +[![Build Status](https://badgers.space/github/checks/rossmacarthur/pycases/trunk?label=build)](https://github.com/rossmacarthur/pycases/actions/workflows/python.yaml) A case conversion library for Python. diff --git a/benches/test_bench.py b/python/benches/test_bench.py similarity index 100% rename from benches/test_bench.py rename to python/benches/test_bench.py diff --git a/cases/__init__.py b/python/cases/__init__.py similarity index 100% rename from cases/__init__.py rename to python/cases/__init__.py diff --git a/cases/__init__.pyi b/python/cases/__init__.pyi similarity index 100% rename from cases/__init__.pyi rename to python/cases/__init__.pyi diff --git a/cases/py.typed b/python/cases/py.typed similarity index 100% rename from cases/py.typed rename to python/cases/py.typed diff --git a/dev/requirements.in b/python/dev/requirements.in similarity index 100% rename from dev/requirements.in rename to python/dev/requirements.in diff --git a/dev/requirements.txt b/python/dev/requirements.txt similarity index 100% rename from dev/requirements.txt rename to python/dev/requirements.txt diff --git a/pyproject.toml b/python/pyproject.toml similarity index 100% rename from pyproject.toml rename to python/pyproject.toml diff --git a/src/lib.rs b/python/src/lib.rs similarity index 100% rename from src/lib.rs rename to python/src/lib.rs diff --git a/src/transform.rs b/python/src/transform.rs similarity index 100% rename from src/transform.rs rename to python/src/transform.rs diff --git a/tests/test_cases.py b/python/tests/test_cases.py similarity index 100% rename from tests/test_cases.py rename to python/tests/test_cases.py