-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
16 changed files
with
1,403 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
name: python | ||
|
||
on: [push, pull_request, workflow_dispatch] | ||
|
||
permissions: | ||
contents: read | ||
|
||
defaults: | ||
run: | ||
working-directory: python | ||
|
||
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: | ||
working-directory: python | ||
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] | ||
steps: | ||
- uses: actions/checkout@v3 | ||
- uses: actions/setup-python@v4 | ||
with: | ||
python-version: '3.11' | ||
- name: Build wheels | ||
uses: PyO3/maturin-action@v1 | ||
with: | ||
working-directory: python | ||
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 | ||
needs: test | ||
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: | ||
working-directory: python | ||
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 | ||
needs: test | ||
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: | ||
working-directory: python | ||
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 | ||
needs: test | ||
steps: | ||
- uses: actions/checkout@v3 | ||
- name: Build sdist | ||
uses: PyO3/maturin-action@v1 | ||
with: | ||
working-directory: python | ||
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 * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
[package] | ||
name = "cases" | ||
version = "0.0.0" | ||
edition = "2021" | ||
publish = false | ||
include = ["/src/**/*", "/*.py", "/*.pyi", "/LICENSE", "/README.md"] | ||
|
||
[lib] | ||
name = "cases" | ||
crate-type = ["cdylib"] | ||
|
||
[dependencies] | ||
pyo3 = "0.19.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
# pycases | ||
|
||
[](https://pypi.org/project/pycases) | ||
[](https://github.com/rossmacarthur/pycases#license) | ||
[](https://github.com/rossmacarthur/pycases/actions/workflows/build.yaml) | ||
|
||
A case conversion library for Python. | ||
|
||
## 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 | | ||
| :---------------------------- | :--------------------- | | ||
| `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 | ||
|
||
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) | ||
|
||
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"}) | ||
'XMLHttpRequest' | ||
>>> cases.to_pascal("xml_http_request", acronyms={"xml": "XML", "http": "HTTP"}) | ||
'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 | 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 | ||
|
||
This project is licensed under the terms of the MIT license. See | ||
[LICENSE](LICENSE) for more details. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
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 to_snake(s: str) -> str: | ||
return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_") | ||
|
||
assert benchmark(to_snake, INPUT) == EXPECT | ||
|
||
|
||
def test_bench_to_snake_python_re(benchmark: BenchmarkFixture): | ||
import re | ||
|
||
pattern = re.compile(r"(?<!^)(?=[A-Z])") | ||
|
||
def to_snake(s: str) -> str: | ||
return pattern.sub("_", s).lower() | ||
|
||
assert benchmark(to_snake, INPUT) == EXPECT | ||
|
||
|
||
def test_bench_to_snake_cases(benchmark: BenchmarkFixture): | ||
from cases import to_snake | ||
|
||
assert benchmark(to_snake, INPUT) == EXPECT | ||
|
||
|
||
def test_bench_to_snake_caseconversion(benchmark: BenchmarkFixture): | ||
from case_conversion import snakecase as to_snake | ||
|
||
assert benchmark(to_snake, INPUT) == EXPECT | ||
|
||
|
||
def test_bench_to_snake_inflection(benchmark: BenchmarkFixture): | ||
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): | ||
from stringcase import snakecase as to_snake | ||
|
||
assert benchmark(to_snake, INPUT) == EXPECT |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from .cases import * | ||
|
||
__doc__ = cases.__doc__ | ||
__all__ = cases.__all__ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' | ||
""" | ||
... |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
maturin | ||
ruff | ||
pytest | ||
pytest-benchmark | ||
|
||
case_conversion | ||
inflection | ||
pydantic | ||
pyheck | ||
stringcase |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
# 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 | ||
# 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 | ||
typing-extensions==4.12.2 | ||
# via | ||
# pydantic | ||
# pydantic-core |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
[build-system] | ||
requires = ["maturin>=1.2,<2.0"] | ||
build-backend = "maturin" | ||
|
||
[tool.maturin] | ||
features = ["pyo3/extension-module"] | ||
|
||
[project] | ||
name = "pycases" | ||
version = "0.1.3" | ||
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", | ||
"Programming Language :: Python :: 3.12", | ||
] | ||
|
||
[project.urls] | ||
Homepage = "https://github.com/rossmacarthur/pycases" | ||
Repository = "https://github.com/rossmacarthur/pycases" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
mod transform; | ||
|
||
use std::fmt; | ||
use std::fmt::Write; | ||
|
||
use pyo3::prelude::*; | ||
use pyo3::types::PyDict; | ||
|
||
use crate::transform::{fmt_lower, fmt_title, fmt_upper}; | ||
|
||
/// Convert a string to 'camelCase'. | ||
#[pyfunction] | ||
#[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 { | ||
match get_acronym(s, acronyms) { | ||
Some(acronym) => write!(buf, "{}", acronym), | ||
None => fmt_title(buf, s), | ||
} | ||
} | ||
}; | ||
|
||
transform::to_string(s, word_fn, "") | ||
} | ||
|
||
/// Convert a string to 'PascalCase'. | ||
#[pyfunction] | ||
#[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), | ||
} | ||
}; | ||
|
||
transform::to_string(s, word_fn, "") | ||
} | ||
|
||
/// Convert a string to 'snake_case'. | ||
#[pyfunction] | ||
fn to_snake(s: &str) -> String { | ||
transform::to_string(s, fmt_lower, "_") | ||
} | ||
|
||
/// Convert a string to 'SCREAMING_SNAKE_CASE'. | ||
#[pyfunction] | ||
fn to_screaming_snake(s: &str) -> String { | ||
transform::to_string(s, fmt_upper, "_") | ||
} | ||
|
||
/// Convert a string to 'kebab-case'. | ||
#[pyfunction] | ||
fn to_kebab(s: &str) -> String { | ||
transform::to_string(s, fmt_lower, "-") | ||
} | ||
|
||
/// Convert a string to 'SCREAMING-KEBAB-CASE'. | ||
#[pyfunction] | ||
fn to_screaming_kebab(s: &str) -> String { | ||
transform::to_string(s, fmt_upper, "-") | ||
} | ||
|
||
/// Convert a string to 'Train-Case'. | ||
#[pyfunction] | ||
#[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), | ||
} | ||
}; | ||
|
||
transform::to_string(s, word_fn, "-") | ||
} | ||
|
||
/// Convert a string to 'lower case'. | ||
#[pyfunction] | ||
fn to_lower(s: &str) -> String { | ||
transform::to_string(s, fmt_lower, " ") | ||
} | ||
|
||
/// Convert a string to 'Title Case'. | ||
#[pyfunction] | ||
#[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), | ||
} | ||
}; | ||
|
||
transform::to_string(s, word_fn, " ") | ||
} | ||
|
||
/// Convert a string to 'UPPER CASE'. | ||
#[pyfunction] | ||
fn to_upper(s: &str) -> String { | ||
transform::to_string(s, fmt_upper, " ") | ||
} | ||
|
||
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<()> { | ||
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(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
use std::fmt; | ||
use std::fmt::Write; | ||
|
||
#[derive(Copy, Clone, PartialEq)] | ||
enum State { | ||
Unknown, | ||
Delims, | ||
Lower, | ||
Upper, | ||
} | ||
|
||
/// Transforms reconstructs the string into a new string using the given | ||
/// functions. | ||
pub fn to_string<F>(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<B, W>(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: usize| -> fmt::Result { | ||
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 => { | ||
if let Some(w1) = w1 { | ||
write(w0, w1)?; | ||
} | ||
w0 = i; | ||
w1 = None; | ||
} | ||
State::Lower if is_upper => { | ||
write(w0, i)?; | ||
w0 = i; | ||
} | ||
State::Upper | ||
if is_upper && matches!(iter.peek(), Some((_, c2)) if c2.is_lowercase()) => | ||
{ | ||
write(w0, i)?; | ||
w0 = i; | ||
} | ||
_ => {} | ||
} | ||
|
||
if is_lower { | ||
state = State::Lower; | ||
} else if is_upper { | ||
state = State::Upper; | ||
} else if state == State::Delims { | ||
state = State::Unknown; | ||
} | ||
} | ||
|
||
match state { | ||
State::Delims => { | ||
if let Some(w1) = w1 { | ||
write(w0, w1)?; | ||
} | ||
} | ||
_ => write(w0, s.len())?, | ||
} | ||
|
||
Ok(()) | ||
} | ||
|
||
pub fn fmt_lower<W: Write>(buf: &mut W, s: &str) -> fmt::Result { | ||
for c in s.chars() { | ||
write!(buf, "{}", c.to_lowercase())? | ||
} | ||
Ok(()) | ||
} | ||
|
||
pub fn fmt_upper<W: Write>(buf: &mut W, s: &str) -> fmt::Result { | ||
for c in s.chars() { | ||
write!(buf, "{}", c.to_uppercase())? | ||
} | ||
Ok(()) | ||
} | ||
|
||
pub fn fmt_title<W: Write>(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(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import re | ||
import os | ||
|
||
import cases | ||
import pytest | ||
|
||
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"), | ||
("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"), | ||
("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_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 | ||
|
||
|
||
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_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" | ||
|
||
|
||
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" | ||
|
||
|
||
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) == 14 | ||
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}""") |