better-profanity-rs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. better_profanity_rs-0.1.0/Cargo.lock +147 -0
  2. better_profanity_rs-0.1.0/Cargo.toml +16 -0
  3. better_profanity_rs-0.1.0/LICENSE +22 -0
  4. better_profanity_rs-0.1.0/NOTICE +18 -0
  5. better_profanity_rs-0.1.0/PKG-INFO +188 -0
  6. better_profanity_rs-0.1.0/README.md +167 -0
  7. better_profanity_rs-0.1.0/bindings/python/Cargo.toml +20 -0
  8. better_profanity_rs-0.1.0/bindings/python/LICENSE +22 -0
  9. better_profanity_rs-0.1.0/bindings/python/NOTICE +18 -0
  10. better_profanity_rs-0.1.0/bindings/python/README.md +167 -0
  11. better_profanity_rs-0.1.0/bindings/python/benchmarks/compare.py +135 -0
  12. better_profanity_rs-0.1.0/bindings/python/benchmarks/data.py +62 -0
  13. better_profanity_rs-0.1.0/bindings/python/benchmarks/parity.py +139 -0
  14. better_profanity_rs-0.1.0/bindings/python/benchmarks/results/m1-pro.json +94 -0
  15. better_profanity_rs-0.1.0/bindings/python/benchmarks/results/parity-3.11.json +67 -0
  16. better_profanity_rs-0.1.0/bindings/python/benchmarks/results/parity-3.13.json +67 -0
  17. better_profanity_rs-0.1.0/bindings/python/benchmarks/results/parity-3.14.json +67 -0
  18. better_profanity_rs-0.1.0/bindings/python/benchmarks/results/parity-3.9.json +67 -0
  19. better_profanity_rs-0.1.0/bindings/python/src/lib.rs +452 -0
  20. better_profanity_rs-0.1.0/bindings/python/tests/conftest.py +35 -0
  21. better_profanity_rs-0.1.0/bindings/python/tests/inputs.py +79 -0
  22. better_profanity_rs-0.1.0/bindings/python/tests/test_parity.py +976 -0
  23. better_profanity_rs-0.1.0/bindings/python/tests/test_upstream.py +211 -0
  24. better_profanity_rs-0.1.0/bindings/python/uv.lock +654 -0
  25. better_profanity_rs-0.1.0/crates/better-profanity/Cargo.toml +17 -0
  26. better_profanity_rs-0.1.0/crates/better-profanity/LICENSE +22 -0
  27. better_profanity_rs-0.1.0/crates/better-profanity/NOTICE +9 -0
  28. better_profanity_rs-0.1.0/crates/better-profanity/README.md +167 -0
  29. better_profanity_rs-0.1.0/crates/better-profanity/data/alphabetic_unicode.json +1 -0
  30. better_profanity_rs-0.1.0/crates/better-profanity/data/profanity_wordlist.txt +916 -0
  31. better_profanity_rs-0.1.0/crates/better-profanity/src/censor.rs +262 -0
  32. better_profanity_rs-0.1.0/crates/better-profanity/src/charset.rs +62 -0
  33. better_profanity_rs-0.1.0/crates/better-profanity/src/data.rs +134 -0
  34. better_profanity_rs-0.1.0/crates/better-profanity/src/lib.rs +394 -0
  35. better_profanity_rs-0.1.0/crates/better-profanity/src/words.rs +221 -0
  36. better_profanity_rs-0.1.0/pyproject.toml +66 -0
  37. better_profanity_rs-0.1.0/python/better_profanity_rs/__init__.py +27 -0
  38. better_profanity_rs-0.1.0/python/better_profanity_rs/_fast.py +294 -0
  39. better_profanity_rs-0.1.0/python/better_profanity_rs/_original.py +64 -0
  40. better_profanity_rs-0.1.0/python/better_profanity_rs/alphabetic_unicode.json +1 -0
  41. better_profanity_rs-0.1.0/python/better_profanity_rs/better_profanity.py +256 -0
  42. better_profanity_rs-0.1.0/python/better_profanity_rs/constants.py +15 -0
  43. better_profanity_rs-0.1.0/python/better_profanity_rs/profanity_wordlist.txt +916 -0
  44. better_profanity_rs-0.1.0/python/better_profanity_rs/utils.py +47 -0
  45. better_profanity_rs-0.1.0/python/better_profanity_rs/varying_string.py +67 -0
@@ -0,0 +1,147 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "better-profanity"
7
+ version = "0.1.0"
8
+ dependencies = [
9
+ "rustc-hash",
10
+ ]
11
+
12
+ [[package]]
13
+ name = "better-profanity-python"
14
+ version = "0.1.0"
15
+ dependencies = [
16
+ "better-profanity",
17
+ "pyo3",
18
+ "rustc-hash",
19
+ ]
20
+
21
+ [[package]]
22
+ name = "heck"
23
+ version = "0.5.0"
24
+ source = "registry+https://github.com/rust-lang/crates.io-index"
25
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
26
+
27
+ [[package]]
28
+ name = "libc"
29
+ version = "0.2.191"
30
+ source = "registry+https://github.com/rust-lang/crates.io-index"
31
+ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
32
+
33
+ [[package]]
34
+ name = "once_cell"
35
+ version = "1.21.4"
36
+ source = "registry+https://github.com/rust-lang/crates.io-index"
37
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
38
+
39
+ [[package]]
40
+ name = "portable-atomic"
41
+ version = "1.15.0"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
44
+
45
+ [[package]]
46
+ name = "proc-macro2"
47
+ version = "1.0.107"
48
+ source = "registry+https://github.com/rust-lang/crates.io-index"
49
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
50
+ dependencies = [
51
+ "unicode-ident",
52
+ ]
53
+
54
+ [[package]]
55
+ name = "pyo3"
56
+ version = "0.29.3"
57
+ source = "registry+https://github.com/rust-lang/crates.io-index"
58
+ checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655"
59
+ dependencies = [
60
+ "libc",
61
+ "once_cell",
62
+ "portable-atomic",
63
+ "pyo3-build-config",
64
+ "pyo3-ffi",
65
+ "pyo3-macros",
66
+ ]
67
+
68
+ [[package]]
69
+ name = "pyo3-build-config"
70
+ version = "0.29.3"
71
+ source = "registry+https://github.com/rust-lang/crates.io-index"
72
+ checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb"
73
+ dependencies = [
74
+ "target-lexicon",
75
+ ]
76
+
77
+ [[package]]
78
+ name = "pyo3-ffi"
79
+ version = "0.29.3"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330"
82
+ dependencies = [
83
+ "libc",
84
+ "pyo3-build-config",
85
+ ]
86
+
87
+ [[package]]
88
+ name = "pyo3-macros"
89
+ version = "0.29.3"
90
+ source = "registry+https://github.com/rust-lang/crates.io-index"
91
+ checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6"
92
+ dependencies = [
93
+ "proc-macro2",
94
+ "pyo3-macros-backend",
95
+ "quote",
96
+ "syn",
97
+ ]
98
+
99
+ [[package]]
100
+ name = "pyo3-macros-backend"
101
+ version = "0.29.3"
102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
103
+ checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3"
104
+ dependencies = [
105
+ "heck",
106
+ "proc-macro2",
107
+ "quote",
108
+ "syn",
109
+ ]
110
+
111
+ [[package]]
112
+ name = "quote"
113
+ version = "1.0.47"
114
+ source = "registry+https://github.com/rust-lang/crates.io-index"
115
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
116
+ dependencies = [
117
+ "proc-macro2",
118
+ ]
119
+
120
+ [[package]]
121
+ name = "rustc-hash"
122
+ version = "2.1.3"
123
+ source = "registry+https://github.com/rust-lang/crates.io-index"
124
+ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
125
+
126
+ [[package]]
127
+ name = "syn"
128
+ version = "2.0.119"
129
+ source = "registry+https://github.com/rust-lang/crates.io-index"
130
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
131
+ dependencies = [
132
+ "proc-macro2",
133
+ "quote",
134
+ "unicode-ident",
135
+ ]
136
+
137
+ [[package]]
138
+ name = "target-lexicon"
139
+ version = "0.13.5"
140
+ source = "registry+https://github.com/rust-lang/crates.io-index"
141
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
142
+
143
+ [[package]]
144
+ name = "unicode-ident"
145
+ version = "1.0.27"
146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
147
+ checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
@@ -0,0 +1,16 @@
1
+ [workspace]
2
+ members = ["crates/better-profanity", "bindings/python"]
3
+ resolver = "2"
4
+
5
+ [workspace.package]
6
+ version = "0.1.0"
7
+ authors = ["Alex Kholodniak <alexandrkholodniak@gmail.com>"]
8
+ edition = "2021"
9
+ rust-version = "1.85"
10
+ license = "MIT"
11
+ repository = "https://github.com/SyntaxSpirits/better-profanity-rs"
12
+ homepage = "https://syntaxspirits.dev"
13
+
14
+ [profile.release]
15
+ lto = "thin"
16
+ codegen-units = 1
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Kholodniak
4
+ Copyright (c) 2018 The Python Packaging Authority
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ better-profanity-rs
2
+ Copyright 2026 Alex Kholodniak
3
+
4
+ This product is a port of better_profanity (https://github.com/snguyenthanh/better_profanity)
5
+ by Son Nguyen Thanh, licensed under the MIT License. Its LICENSE file reads
6
+ "Copyright (c) 2018 The Python Packaging Authority"; that notice is kept in LICENSE.
7
+
8
+ The following files are copies of better_profanity 0.7.0, unchanged:
9
+
10
+ - crates/better-profanity/data/profanity_wordlist.txt and
11
+ bindings/python/python/better_profanity_rs/profanity_wordlist.txt (the word list);
12
+ - crates/better-profanity/data/alphabetic_unicode.json and
13
+ bindings/python/python/better_profanity_rs/alphabetic_unicode.json;
14
+ - bindings/python/python/better_profanity_rs/better_profanity.py, constants.py, utils.py and
15
+ varying_string.py, which the Python package runs for inputs the Rust core does not handle.
16
+
17
+ bindings/python/python/better_profanity_rs/_original.py holds the source of
18
+ Profanity._hide_swear_words from better_profanity 0.7.0 as a string, unchanged.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: better-profanity-rs
3
+ Version: 0.1.0
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Intended Audience :: Developers
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Rust
8
+ Classifier: Topic :: Text Processing :: Filters
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Summary: Fast drop-in replacement for better_profanity: the same censored text from a Rust core
12
+ Keywords: profanity,better_profanity,censor,swear-words,moderation,rust
13
+ Author-email: Alex Kholodniak <alexandrkholodniak@gmail.com>
14
+ License-Expression: MIT
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
17
+ Project-URL: Homepage, https://syntaxspirits.dev
18
+ Project-URL: Issues, https://github.com/SyntaxSpirits/better-profanity-rs/issues
19
+ Project-URL: Repository, https://github.com/SyntaxSpirits/better-profanity-rs
20
+
21
+ # better-profanity-rs
22
+
23
+ [![CI](https://github.com/SyntaxSpirits/better-profanity-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/SyntaxSpirits/better-profanity-rs/actions/workflows/ci.yml)
24
+ [![PyPI](https://img.shields.io/pypi/v/better-profanity-rs.svg)](https://pypi.org/project/better-profanity-rs/)
25
+ [![Crates.io](https://img.shields.io/crates/v/better-profanity.svg)](https://crates.io/crates/better-profanity)
26
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
27
+
28
+ [`better_profanity`](https://pypi.org/project/better-profanity/) with a Rust core: the same
29
+ censored text for every input, about 2,100 times faster on a 10-word message and
30
+ 10,000 to 12,000 times faster on texts of 100 to 700 words
31
+ ([benchmarks](https://github.com/SyntaxSpirits/better-profanity-rs/blob/main/BENCHMARKS.md)).
32
+
33
+ `better_profanity` is downloaded about two million times a month, although its last release, 0.7.0,
34
+ dates from November 2020. It is slow: `contains_profanity` takes 9 ms for a 10-word message and
35
+ 0.9 s for a 700-word article on an M1 Pro with CPython 3.13. The word list is a Python list of 916
36
+ objects, and each word of the text, alone and joined with the words after it, is compared with every
37
+ one of them by a method that tries the leetspeak variants letter by letter. `better-profanity-rs`
38
+ turns the word list into a trie whose edges are sets of characters (`a`, `@`, `*` and `4` for an
39
+ `a`) and runs the original scan, step for step, in Rust. Words in leetspeak, phrases split by
40
+ separators (`f.u.c.k`, `H4nd j0b`), whitelists, custom word lists and the quirks of the original
41
+ give the same output.
42
+
43
+ The Python package is `better-profanity-rs`; the Rust crate is `better-profanity`. This is an
44
+ independent project, not affiliated with the author of `better_profanity`.
45
+
46
+ ## Python
47
+
48
+ ```sh
49
+ pip install better-profanity-rs
50
+ ```
51
+
52
+ Change the import and keep the rest of your code:
53
+
54
+ ```python
55
+ from better_profanity_rs import profanity # was: from better_profanity import profanity
56
+
57
+ profanity.censor("That wh0re gave m3 a very good H4nd j0b, dude.")
58
+ # 'That **** gave m3 a very good ****, dude.'
59
+ profanity.contains_profanity("he is a m0th3rf*cker")
60
+ # True
61
+ profanity.load_censor_words(["happy", "jolly"], whitelist_words=["jolly"])
62
+ profanity.censor("Happy and jolly days", "-")
63
+ # '---- and jolly days'
64
+ ```
65
+
66
+ `Profanity`, `load_censor_words`, `load_censor_words_from_file`, `add_censor_words`, `censor`
67
+ and `contains_profanity` take the same arguments as in `better_profanity` 0.7.0 and return the
68
+ same results or raise the same exceptions. The attributes `CENSOR_WORDSET`, `CHARS_MAPPING`,
69
+ `MAX_NUMBER_COMBINATIONS` and `ALLOWED_CHARACTERS` can be read and changed as before, and the
70
+ modules `better_profanity`, `constants`, `utils` and `varying_string` are those of 0.7.0.
71
+
72
+ ### Code you do not control
73
+
74
+ When a library imports `better_profanity` itself, call `install()` before importing it:
75
+
76
+ ```python
77
+ import better_profanity_rs
78
+
79
+ better_profanity_rs.install() # `import better_profanity` now returns better_profanity_rs
80
+
81
+ import my_web_app
82
+ ```
83
+
84
+ `install()` covers `better_profanity` and its modules `better_profanity`, `constants`, `utils`
85
+ and `varying_string`. Modules that imported `better_profanity` before the call keep the
86
+ original.
87
+
88
+ ## Rust
89
+
90
+ ```toml
91
+ [dependencies]
92
+ better-profanity = "0.1"
93
+ ```
94
+
95
+ ```rust
96
+ use better_profanity::Profanity;
97
+
98
+ let profanity = Profanity::new();
99
+ assert_eq!(
100
+ profanity.censor("That wh0re gave m3 a very good H@nD j0b.", "*"),
101
+ "That **** gave m3 a very good ****."
102
+ );
103
+
104
+ let mut custom = Profanity::with_words(["happy", "jolly"]);
105
+ custom.add_censor_words(["merry christmas"]);
106
+ assert!(custom.contains_profanity("Merry Christmas!"));
107
+ ```
108
+
109
+ `Profanity` follows the Python class; `hide_swear_words`, `WordSet` and `CharSet` expose the
110
+ scan for other word sets, separator sets and lowercasing rules. The crate lowercases with Rust's
111
+ Unicode tables, which can differ from those of a given Python for characters added to Unicode
112
+ recently; the Python package always uses the running interpreter's `str.lower()`.
113
+
114
+ ## Compatibility
115
+
116
+ The tests compare `better-profanity-rs` with `better_profanity` 0.7.0 and fail on any
117
+ difference in output or exception: the original's own test suite, hand-picked cases,
118
+ dictionary words in leetspeak inside texts and joined by separators, custom word lists and
119
+ whitelists, invalid arguments, every kind of attribute change listed below, subclasses,
120
+ pickling, threads and property-based tests, on CPython 3.9 to 3.14. A separate script compares
121
+ 285,074 texts, among them the civil_comments test split and the Jigsaw toxic comments (50,532
122
+ of the texts are censored), and 28,183 of them again with a custom word list and whitelist, on
123
+ CPython 3.9, 3.11, 3.13 and 3.14: no output differs (see [BENCHMARKS.md](BENCHMARKS.md)).
124
+
125
+ This includes the original's quirks. For example, `add_censor_words` does not raise
126
+ `MAX_NUMBER_COMBINATIONS`, and when it is 1, only every other word is joined to the next one.
127
+ After `Profanity(["happy", "jolly"]).add_censor_words(["merry christmas"])`, both packages
128
+ censor "m3rry christmas" and "a b m3rry christmas" but not "a m3rry christmas".
129
+
130
+ `better_profanity`'s own code, bundled in the package, runs whenever the Rust core could
131
+ behave differently, so the output stays the same and only the speed-up is lost:
132
+
133
+ - the text or `censor_char` is not an exact `str` (a `str` subclass, for example), or contains
134
+ lone surrogates;
135
+ - `CENSOR_WORDSET` is not an exact list or tuple of `VaryingString` objects and strings, or a
136
+ `CHARS_MAPPING` value used by a word has a variant that is not exactly one character
137
+ (`better_profanity` also accepts longer and empty variants);
138
+ - `MAX_NUMBER_COMBINATIONS` is not an `int`, or the call is made so close to the recursion
139
+ limit that the original, which recurses `MAX_NUMBER_COMBINATIONS` calls deep, could reach
140
+ it (within about twice `MAX_NUMBER_COMBINATIONS` + 20 frames);
141
+ - `ALLOWED_CHARACTERS` was replaced by another set, or holds something other than strings;
142
+ - a subclass or a patch replaces `_get_next_words`, `_update_next_words_indices`,
143
+ `_get_start_index_of_next_word`, `_get_next_word_and_end_index`,
144
+ `get_replacement_for_swear_word`, `any_next_words_form_swear_word` or
145
+ `VaryingString.__eq__`, or turns one of the attributes into a property.
146
+
147
+ The original runs in the same stack frame as in `better_profanity`, so a `RecursionError` is
148
+ raised exactly where `better_profanity` raises it. Changes in place are seen. `CENSOR_WORDSET` is compiled again when its items change: each call
149
+ checks that the list still holds the same objects, which takes about 1 µs for the default
150
+ list. `ALLOWED_CHARACTERS` is a `set` subclass that notes its changes. Lowercasing uses the
151
+ interpreter's `str.lower()`, so, as with `better_profanity`, results follow the Unicode version
152
+ of the Python running them.
153
+
154
+ Known differences:
155
+
156
+ - The private attributes of a `VaryingString` (`_char_combos`, `_min_len`, `_max_len`) are
157
+ read when the word list is compiled; changing them later on an object already in
158
+ `CENSOR_WORDSET` is not seen.
159
+ - `ALLOWED_CHARACTERS` is a `set` subclass; changes made through `set` itself, as in
160
+ `set.add(ALLOWED_CHARACTERS, "-")`, are not seen.
161
+ - Near the recursion limit, frames are counted with `sys._getframe`. CPython 3.11 and older
162
+ also count nested C calls toward the limit; the check allows one per Python frame, so a call
163
+ made deep inside C-level recursion (a recursive `__repr__`, for example) could still raise
164
+ where `better_profanity` would not, or the reverse. On CPython 3.11, a call made within two
165
+ frames of the limit raises `RecursionError` as `better_profanity` does, but the message can
166
+ name a different operation.
167
+ - A compiled word list keeps the `VaryingString` objects of `CENSOR_WORDSET` alive until the
168
+ list changes or the `Profanity` object is deleted. An attribute added to one of them that
169
+ refers back to the `Profanity` object keeps that object alive.
170
+ - Free-threaded CPython builds are not supported: the extension module asks to keep the GIL
171
+ enabled.
172
+ - `__version__` and `name` are those of `better_profanity_rs`.
173
+
174
+ ## Development
175
+
176
+ ```sh
177
+ cargo test -p better-profanity
178
+ cd bindings/python
179
+ uv sync --group dev
180
+ uv run pytest
181
+ cd benchmarks && uv run --group bench python parity.py # all corpora
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT, the same licence as `better_profanity`, whose code, word list and character list this
187
+ project includes. See [NOTICE](NOTICE).
188
+
@@ -0,0 +1,167 @@
1
+ # better-profanity-rs
2
+
3
+ [![CI](https://github.com/SyntaxSpirits/better-profanity-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/SyntaxSpirits/better-profanity-rs/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/better-profanity-rs.svg)](https://pypi.org/project/better-profanity-rs/)
5
+ [![Crates.io](https://img.shields.io/crates/v/better-profanity.svg)](https://crates.io/crates/better-profanity)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ [`better_profanity`](https://pypi.org/project/better-profanity/) with a Rust core: the same
9
+ censored text for every input, about 2,100 times faster on a 10-word message and
10
+ 10,000 to 12,000 times faster on texts of 100 to 700 words
11
+ ([benchmarks](https://github.com/SyntaxSpirits/better-profanity-rs/blob/main/BENCHMARKS.md)).
12
+
13
+ `better_profanity` is downloaded about two million times a month, although its last release, 0.7.0,
14
+ dates from November 2020. It is slow: `contains_profanity` takes 9 ms for a 10-word message and
15
+ 0.9 s for a 700-word article on an M1 Pro with CPython 3.13. The word list is a Python list of 916
16
+ objects, and each word of the text, alone and joined with the words after it, is compared with every
17
+ one of them by a method that tries the leetspeak variants letter by letter. `better-profanity-rs`
18
+ turns the word list into a trie whose edges are sets of characters (`a`, `@`, `*` and `4` for an
19
+ `a`) and runs the original scan, step for step, in Rust. Words in leetspeak, phrases split by
20
+ separators (`f.u.c.k`, `H4nd j0b`), whitelists, custom word lists and the quirks of the original
21
+ give the same output.
22
+
23
+ The Python package is `better-profanity-rs`; the Rust crate is `better-profanity`. This is an
24
+ independent project, not affiliated with the author of `better_profanity`.
25
+
26
+ ## Python
27
+
28
+ ```sh
29
+ pip install better-profanity-rs
30
+ ```
31
+
32
+ Change the import and keep the rest of your code:
33
+
34
+ ```python
35
+ from better_profanity_rs import profanity # was: from better_profanity import profanity
36
+
37
+ profanity.censor("That wh0re gave m3 a very good H4nd j0b, dude.")
38
+ # 'That **** gave m3 a very good ****, dude.'
39
+ profanity.contains_profanity("he is a m0th3rf*cker")
40
+ # True
41
+ profanity.load_censor_words(["happy", "jolly"], whitelist_words=["jolly"])
42
+ profanity.censor("Happy and jolly days", "-")
43
+ # '---- and jolly days'
44
+ ```
45
+
46
+ `Profanity`, `load_censor_words`, `load_censor_words_from_file`, `add_censor_words`, `censor`
47
+ and `contains_profanity` take the same arguments as in `better_profanity` 0.7.0 and return the
48
+ same results or raise the same exceptions. The attributes `CENSOR_WORDSET`, `CHARS_MAPPING`,
49
+ `MAX_NUMBER_COMBINATIONS` and `ALLOWED_CHARACTERS` can be read and changed as before, and the
50
+ modules `better_profanity`, `constants`, `utils` and `varying_string` are those of 0.7.0.
51
+
52
+ ### Code you do not control
53
+
54
+ When a library imports `better_profanity` itself, call `install()` before importing it:
55
+
56
+ ```python
57
+ import better_profanity_rs
58
+
59
+ better_profanity_rs.install() # `import better_profanity` now returns better_profanity_rs
60
+
61
+ import my_web_app
62
+ ```
63
+
64
+ `install()` covers `better_profanity` and its modules `better_profanity`, `constants`, `utils`
65
+ and `varying_string`. Modules that imported `better_profanity` before the call keep the
66
+ original.
67
+
68
+ ## Rust
69
+
70
+ ```toml
71
+ [dependencies]
72
+ better-profanity = "0.1"
73
+ ```
74
+
75
+ ```rust
76
+ use better_profanity::Profanity;
77
+
78
+ let profanity = Profanity::new();
79
+ assert_eq!(
80
+ profanity.censor("That wh0re gave m3 a very good H@nD j0b.", "*"),
81
+ "That **** gave m3 a very good ****."
82
+ );
83
+
84
+ let mut custom = Profanity::with_words(["happy", "jolly"]);
85
+ custom.add_censor_words(["merry christmas"]);
86
+ assert!(custom.contains_profanity("Merry Christmas!"));
87
+ ```
88
+
89
+ `Profanity` follows the Python class; `hide_swear_words`, `WordSet` and `CharSet` expose the
90
+ scan for other word sets, separator sets and lowercasing rules. The crate lowercases with Rust's
91
+ Unicode tables, which can differ from those of a given Python for characters added to Unicode
92
+ recently; the Python package always uses the running interpreter's `str.lower()`.
93
+
94
+ ## Compatibility
95
+
96
+ The tests compare `better-profanity-rs` with `better_profanity` 0.7.0 and fail on any
97
+ difference in output or exception: the original's own test suite, hand-picked cases,
98
+ dictionary words in leetspeak inside texts and joined by separators, custom word lists and
99
+ whitelists, invalid arguments, every kind of attribute change listed below, subclasses,
100
+ pickling, threads and property-based tests, on CPython 3.9 to 3.14. A separate script compares
101
+ 285,074 texts, among them the civil_comments test split and the Jigsaw toxic comments (50,532
102
+ of the texts are censored), and 28,183 of them again with a custom word list and whitelist, on
103
+ CPython 3.9, 3.11, 3.13 and 3.14: no output differs (see [BENCHMARKS.md](BENCHMARKS.md)).
104
+
105
+ This includes the original's quirks. For example, `add_censor_words` does not raise
106
+ `MAX_NUMBER_COMBINATIONS`, and when it is 1, only every other word is joined to the next one.
107
+ After `Profanity(["happy", "jolly"]).add_censor_words(["merry christmas"])`, both packages
108
+ censor "m3rry christmas" and "a b m3rry christmas" but not "a m3rry christmas".
109
+
110
+ `better_profanity`'s own code, bundled in the package, runs whenever the Rust core could
111
+ behave differently, so the output stays the same and only the speed-up is lost:
112
+
113
+ - the text or `censor_char` is not an exact `str` (a `str` subclass, for example), or contains
114
+ lone surrogates;
115
+ - `CENSOR_WORDSET` is not an exact list or tuple of `VaryingString` objects and strings, or a
116
+ `CHARS_MAPPING` value used by a word has a variant that is not exactly one character
117
+ (`better_profanity` also accepts longer and empty variants);
118
+ - `MAX_NUMBER_COMBINATIONS` is not an `int`, or the call is made so close to the recursion
119
+ limit that the original, which recurses `MAX_NUMBER_COMBINATIONS` calls deep, could reach
120
+ it (within about twice `MAX_NUMBER_COMBINATIONS` + 20 frames);
121
+ - `ALLOWED_CHARACTERS` was replaced by another set, or holds something other than strings;
122
+ - a subclass or a patch replaces `_get_next_words`, `_update_next_words_indices`,
123
+ `_get_start_index_of_next_word`, `_get_next_word_and_end_index`,
124
+ `get_replacement_for_swear_word`, `any_next_words_form_swear_word` or
125
+ `VaryingString.__eq__`, or turns one of the attributes into a property.
126
+
127
+ The original runs in the same stack frame as in `better_profanity`, so a `RecursionError` is
128
+ raised exactly where `better_profanity` raises it. Changes in place are seen. `CENSOR_WORDSET` is compiled again when its items change: each call
129
+ checks that the list still holds the same objects, which takes about 1 µs for the default
130
+ list. `ALLOWED_CHARACTERS` is a `set` subclass that notes its changes. Lowercasing uses the
131
+ interpreter's `str.lower()`, so, as with `better_profanity`, results follow the Unicode version
132
+ of the Python running them.
133
+
134
+ Known differences:
135
+
136
+ - The private attributes of a `VaryingString` (`_char_combos`, `_min_len`, `_max_len`) are
137
+ read when the word list is compiled; changing them later on an object already in
138
+ `CENSOR_WORDSET` is not seen.
139
+ - `ALLOWED_CHARACTERS` is a `set` subclass; changes made through `set` itself, as in
140
+ `set.add(ALLOWED_CHARACTERS, "-")`, are not seen.
141
+ - Near the recursion limit, frames are counted with `sys._getframe`. CPython 3.11 and older
142
+ also count nested C calls toward the limit; the check allows one per Python frame, so a call
143
+ made deep inside C-level recursion (a recursive `__repr__`, for example) could still raise
144
+ where `better_profanity` would not, or the reverse. On CPython 3.11, a call made within two
145
+ frames of the limit raises `RecursionError` as `better_profanity` does, but the message can
146
+ name a different operation.
147
+ - A compiled word list keeps the `VaryingString` objects of `CENSOR_WORDSET` alive until the
148
+ list changes or the `Profanity` object is deleted. An attribute added to one of them that
149
+ refers back to the `Profanity` object keeps that object alive.
150
+ - Free-threaded CPython builds are not supported: the extension module asks to keep the GIL
151
+ enabled.
152
+ - `__version__` and `name` are those of `better_profanity_rs`.
153
+
154
+ ## Development
155
+
156
+ ```sh
157
+ cargo test -p better-profanity
158
+ cd bindings/python
159
+ uv sync --group dev
160
+ uv run pytest
161
+ cd benchmarks && uv run --group bench python parity.py # all corpora
162
+ ```
163
+
164
+ ## License
165
+
166
+ MIT, the same licence as `better_profanity`, whose code, word list and character list this
167
+ project includes. See [NOTICE](NOTICE).
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "better-profanity-python"
3
+ description = "Python bindings for better-profanity"
4
+ publish = false
5
+ version.workspace = true
6
+ authors.workspace = true
7
+ edition.workspace = true
8
+ rust-version.workspace = true
9
+ license.workspace = true
10
+ repository.workspace = true
11
+ readme = "README.md"
12
+
13
+ [lib]
14
+ name = "_core"
15
+ crate-type = ["cdylib"]
16
+
17
+ [dependencies]
18
+ pyo3 = { version = "0.29", features = ["abi3-py39"] }
19
+ better-profanity = { path = "../../crates/better-profanity" }
20
+ rustc-hash = "2"
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Kholodniak
4
+ Copyright (c) 2018 The Python Packaging Authority
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ better-profanity-rs
2
+ Copyright 2026 Alex Kholodniak
3
+
4
+ This product is a port of better_profanity (https://github.com/snguyenthanh/better_profanity)
5
+ by Son Nguyen Thanh, licensed under the MIT License. Its LICENSE file reads
6
+ "Copyright (c) 2018 The Python Packaging Authority"; that notice is kept in LICENSE.
7
+
8
+ The following files are copies of better_profanity 0.7.0, unchanged:
9
+
10
+ - crates/better-profanity/data/profanity_wordlist.txt and
11
+ bindings/python/python/better_profanity_rs/profanity_wordlist.txt (the word list);
12
+ - crates/better-profanity/data/alphabetic_unicode.json and
13
+ bindings/python/python/better_profanity_rs/alphabetic_unicode.json;
14
+ - bindings/python/python/better_profanity_rs/better_profanity.py, constants.py, utils.py and
15
+ varying_string.py, which the Python package runs for inputs the Rust core does not handle.
16
+
17
+ bindings/python/python/better_profanity_rs/_original.py holds the source of
18
+ Profanity._hide_swear_words from better_profanity 0.7.0 as a string, unchanged.