hexrgba 0.2.1__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 (36) hide show
  1. hexrgba-0.2.1/.gitignore +16 -0
  2. hexrgba-0.2.1/CHANGELOG.md +42 -0
  3. hexrgba-0.2.1/GUIDE.md +145 -0
  4. hexrgba-0.2.1/LICENSE +21 -0
  5. hexrgba-0.2.1/PKG-INFO +109 -0
  6. hexrgba-0.2.1/README.md +82 -0
  7. hexrgba-0.2.1/pyproject.toml +72 -0
  8. hexrgba-0.2.1/scripts/e2e_wheel.py +106 -0
  9. hexrgba-0.2.1/scripts/fetch_upstream.py +37 -0
  10. hexrgba-0.2.1/src/hexrgba/__init__.py +36 -0
  11. hexrgba-0.2.1/src/hexrgba/__main__.py +10 -0
  12. hexrgba-0.2.1/src/hexrgba/_alpha.py +56 -0
  13. hexrgba-0.2.1/src/hexrgba/_blend.py +62 -0
  14. hexrgba-0.2.1/src/hexrgba/_cli.py +125 -0
  15. hexrgba-0.2.1/src/hexrgba/_color.py +141 -0
  16. hexrgba-0.2.1/src/hexrgba/_errors.py +32 -0
  17. hexrgba-0.2.1/src/hexrgba/_hex.py +89 -0
  18. hexrgba-0.2.1/src/hexrgba/_names.py +164 -0
  19. hexrgba-0.2.1/src/hexrgba/_wcag.py +70 -0
  20. hexrgba-0.2.1/src/hexrgba/py.typed +0 -0
  21. hexrgba-0.2.1/tests/conftest.py +41 -0
  22. hexrgba-0.2.1/tests/fixtures/css-color-4-named-colors.json +156 -0
  23. hexrgba-0.2.1/tests/test_alpha.py +76 -0
  24. hexrgba-0.2.1/tests/test_blend.py +56 -0
  25. hexrgba-0.2.1/tests/test_browser_cases.py +45 -0
  26. hexrgba-0.2.1/tests/test_cli.py +128 -0
  27. hexrgba-0.2.1/tests/test_color.py +108 -0
  28. hexrgba-0.2.1/tests/test_contract.py +46 -0
  29. hexrgba-0.2.1/tests/test_docs.py +35 -0
  30. hexrgba-0.2.1/tests/test_e2e_wheel.py +25 -0
  31. hexrgba-0.2.1/tests/test_fuzz.py +107 -0
  32. hexrgba-0.2.1/tests/test_hex.py +119 -0
  33. hexrgba-0.2.1/tests/test_names.py +82 -0
  34. hexrgba-0.2.1/tests/test_wcag.py +86 -0
  35. hexrgba-0.2.1/tests/test_wpt.py +82 -0
  36. hexrgba-0.2.1/tests/upstream/MANIFEST.json +47 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party test files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] - 2026-10-13
10
+
11
+ ### Changed
12
+
13
+ - The Web Platform Tests color parsing pages are no longer committed or shipped in the
14
+ sdist: `scripts/fetch_upstream.py` fetches them at a pinned commit, checked against
15
+ `tests/upstream/MANIFEST.json`, and hand-written cases keep the default suite meaningful
16
+ without them.
17
+
18
+ ## [0.2.0] - Unreleased
19
+
20
+ ### Added
21
+
22
+ - `parse_color` and `is_color`: hex colors plus all 148 CSS named colors and
23
+ `transparent`, matched ASCII case-insensitively, with a `strict` mode that follows the
24
+ CSS grammar exactly.
25
+ - `Color`, a frozen, hashable value type with `hex`, `to_hex(short=...)`, `to_css()`,
26
+ `name`, `alpha`, `is_opaque`, `with_alpha()`, `from_rgba()` and `as_tuple()`.
27
+ - `NAMED_COLORS`, the read-only CSS Color 4 named color table.
28
+ - `alpha_to_byte` and `css_alpha`: exact alpha conversion and the CSS legacy alpha
29
+ serialization; every alpha byte round-trips through both.
30
+ - `shorten` and `normalize(..., short=True)` for the lossless `#rgb` / `#rgba` form.
31
+ - `relative_luminance`, `contrast_ratio` and `passes_contrast` (WCAG 2.x, AA and AAA).
32
+ - `blend`, simple alpha compositing of one color over another.
33
+ - `ColorError`, `ColorNameError` and `ChannelError`. `HexColorError` is now a
34
+ `ColorError`, still a `ValueError`.
35
+ - The `hexrgba` command (`normalize`, `convert`, `contrast`, `blend`) and `python -m hexrgba`.
36
+ - `GUIDE.md`, with the exact rules and their sources.
37
+
38
+ ## [0.1.0]
39
+
40
+ - First release: `is_hex`, `parse`, `normalize`, `to_hex` and `HexColorError`.
41
+ - Support for `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa`, with optional leading hash,
42
+ any case, shorthand expansion and alpha round-tripping.
hexrgba-0.2.1/GUIDE.md ADDED
@@ -0,0 +1,145 @@
1
+ # hexrgba guide
2
+
3
+ This guide states the exact rules `hexrgba` follows and where each one comes from. All
4
+ sources were fetched on 2026-10-12.
5
+
6
+ | Topic | Source |
7
+ | --- | --- |
8
+ | Hex syntax, named colors, `transparent`, serialization | [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/), Candidate Recommendation Draft of 9 October 2026, sections 5.2, 6.1, 6.3 and 16 |
9
+ | Golden parsing vectors | [Web Platform Tests `css/css-color/parsing`](https://github.com/web-platform-tests/wpt/tree/786385c76ca1c01d40eba0234dc59384e2eb73d4/css/css-color/parsing), commit `786385c` |
10
+ | Luminance and contrast | [WCAG 2.2](https://www.w3.org/TR/WCAG22/), Recommendation of 12 December 2024 |
11
+ | Blending | [Compositing and Blending Level 1](https://www.w3.org/TR/compositing-1/), section 5.1 |
12
+
13
+ ## Parsing
14
+
15
+ `parse_color(text, *, strict=False)` returns a `Color`. It accepts:
16
+
17
+ - `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa`. Short forms expand by doubling each digit,
18
+ so `#123` is `#112233`. Without an alpha pair the color is opaque (alpha byte 255).
19
+ - The 148 keywords of the CSS named color table (it includes `rebeccapurple` and both
20
+ spellings of every gray), and `transparent`, which is `rgba(0, 0, 0, 0)`.
21
+
22
+ Hex digits and keywords are **ASCII** case-insensitive: only `A` to `Z` fold. That matters
23
+ because Python's `str.lower()` folds more: the Kelvin sign `U+212A` lowers to `k`, so a
24
+ naive parser would accept `blac` + Kelvin sign as `black`. CSS rejects it, the WPT suite
25
+ tests it, and so does `hexrgba`.
26
+
27
+ | Mode | Leading `#` | Surrounding whitespace | Upper case |
28
+ | --- | --- | --- | --- |
29
+ | `strict=True` | required for hex | rejected | accepted (CSS is case-insensitive) |
30
+ | lenient (default) | optional | stripped with `str.strip()` (any Python whitespace) | accepted |
31
+
32
+ No keyword is made only of hex digits, so lenient mode can never confuse a name with a
33
+ hash-less hex color; a test pins that.
34
+
35
+ `currentcolor`, system colors and the functional notations (`rgb()`, `hsl()`, `color()`)
36
+ are not accepted as input: they need a cascade or a much larger grammar.
37
+
38
+ The `0.1.0` functions (`is_hex`, `parse`, `normalize`, `to_hex`) keep their exact
39
+ behaviour: hex only, lenient, `(r, g, b, a)` tuples.
40
+
41
+ ## Output
42
+
43
+ - `Color.hex` and `normalize()` give canonical lower-case `#rrggbb` when opaque and
44
+ `#rrggbbaa` otherwise.
45
+ - `to_hex(short=True)`, `normalize(..., short=True)` and `shorten()` give `#rgb` or `#rgba`
46
+ only when that is lossless (`#aabbcc` to `#abc`, `#aabbcd` stays long).
47
+ - `Color.to_css()` follows "CSS serialization of sRGB values" (section 16.2.2): `rgb(r, g, b)`
48
+ when the alpha byte is 255, else `rgba(r, g, b, alpha)`, comma separated with one space.
49
+ The WPT expected strings are used as the test oracle.
50
+ - `Color.name` gives the keyword for an exact match, or `None`. Where two keywords share a
51
+ value (`aqua` and `cyan`, `fuchsia` and `magenta`, `gray` and `grey`) the first in the
52
+ spec's alphabetical table wins.
53
+
54
+ ## Alpha: 0..255 versus 0..1
55
+
56
+ The alpha is stored as a byte, `Color.a`, from 0 to 255. There are two float views and
57
+ one way back:
58
+
59
+ - `Color.alpha` is exactly `a / 255`.
60
+ - `css_alpha(a)` is the CSS legacy alpha string (section 16.1.1). If a whole percentage
61
+ `n` satisfies `round(n * 2.55) == a`, the result is `n / 100` (237 gives `0.93`, 204
62
+ gives `0.8`, 128 gives `0.5`). Otherwise it is `a / 0.255` rounded and divided by 1000
63
+ (236 gives `0.925`). Halves round up. The arithmetic is exact, not binary floating point.
64
+ - `alpha_to_byte(x)` multiplies by 255 and rounds halves up. A float is read as its
65
+ shortest decimal form, the number you typed: `0.3` is 76.5 and gives 77, where the
66
+ binary value just below 0.3 would give 76.
67
+
68
+ **Round-trip contract**, tested for all 256 bytes:
69
+
70
+ ```python
71
+ alpha_to_byte(a / 255) == a
72
+ alpha_to_byte(float(css_alpha(a))) == a
73
+ ```
74
+
75
+ So `#rrggbbaa` to `Color.alpha` (or to the `rgba()` string) and back to hex is lossless.
76
+ The other direction cannot be: a byte has 256 values, so `alpha_to_byte(0.123)` gives 31,
77
+ and `css_alpha(31)` is `0.12`, which reads back as 31, not as 0.123.
78
+
79
+ One note on the spec: for byte 236 its prose example prints `0.92549`, but its own
80
+ numbered steps give `0.925`. `hexrgba` follows the numbered steps.
81
+
82
+ ## Contrast
83
+
84
+ `relative_luminance(color)` is `0.2126 R + 0.7152 G + 0.0722 B` on linearized sRGB
85
+ channels, using the `0.04045` threshold of the current WCAG definition (older texts said
86
+ `0.03928`; no 8-bit value falls between the two, so results are identical). Alpha is
87
+ ignored.
88
+
89
+ `contrast_ratio(fg, bg)` is `(L1 + 0.05) / (L2 + 0.05)` with `L1` the lighter, from 1 to 21.
90
+ A translucent foreground is first blended over the background and rounded to 8-bit
91
+ channels, as a browser paints it; the ratio is measured on that painted color. The background must be
92
+ opaque, because what lies behind it is unknown.
93
+
94
+ `passes_contrast(fg, bg, level="AA", large=False)` compares without rounding, as WCAG
95
+ requires (4.499 fails 4.5):
96
+
97
+ | Level | Normal text | Large text |
98
+ | --- | --- | --- |
99
+ | AA (1.4.3) | 4.5 | 3 |
100
+ | AAA (1.4.6) | 7 | 4.5 |
101
+
102
+ The test suite checks the worked examples published by WCAG (for example `#949494` on
103
+ white is 3:1, `#E3660E` on white is 3.4:1). WCAG prints those with one decimal and does not
104
+ round consistently, so each is checked to within 0.1.
105
+
106
+ ## Blending
107
+
108
+ `blend(fg, bg)` is source-over simple alpha compositing:
109
+
110
+ ```
111
+ ao = as + ab (1 - as)
112
+ Co = (Cs as + Cb ab (1 - as)) / ao
113
+ ```
114
+
115
+ It is computed with exact fractions and each channel is rounded to a byte once, halves
116
+ up. If both inputs are fully transparent the result is transparent black.
117
+
118
+ ## Errors
119
+
120
+ All errors are `ValueError` subclasses, so `0.1.0` handlers keep working:
121
+
122
+ - `ColorError`: base class; also raised for a translucent contrast background.
123
+ - `HexColorError`: malformed hex, or a hash-less hex in strict mode.
124
+ - `ChannelError` (a `HexColorError`): a channel or alpha outside its range or of the
125
+ wrong type, including booleans.
126
+ - `ColorNameError`: neither hex nor a known keyword.
127
+
128
+ `parse_color` raises `TypeError` for a non-string. `is_color` never raises.
129
+ `is_hex` and `parse` keep their 0.1.0 behaviour and raise `AttributeError` for a non-string.
130
+ Error messages show at most the first 40 characters of the offending value.
131
+
132
+ ## Command line
133
+
134
+ ```
135
+ hexrgba normalize COLOR [--short] [--strict]
136
+ hexrgba convert COLOR [--to hex|short|css|channels|name] [--strict]
137
+ hexrgba contrast FOREGROUND BACKGROUND [--level AA|AAA] [--large]
138
+ hexrgba blend FOREGROUND BACKGROUND
139
+ ```
140
+
141
+ `contrast` prints the ratio truncated to two decimals (a failing 4.499 never shows as
142
+ 4.50); with `--level` it exits 1 when the pair fails. `--large` is only valid with `--level`.
143
+ Bad usage and invalid colors print to stderr and exit 2, so a script can tell a failing
144
+ pair from bad input. Every example in `hexrgba --help` is executed by the
145
+ test suite and by the wheel end-to-end check.
hexrgba-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hexrgba-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: hexrgba
3
+ Version: 0.2.1
4
+ Summary: CSS hex and named colors: parse, normalize, exact alpha, rgba(), WCAG contrast and blending
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/hexrgba
7
+ Project-URL: Issues, https://github.com/JaviLianes8/hexrgba/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/hexrgba/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: blend,color,contrast,css,hex,named-colors,normalize,parse,rgba,wcag
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: build; extra == 'dev'
22
+ Requires-Dist: mypy; extra == 'dev'
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: ruff; extra == 'dev'
25
+ Requires-Dist: twine; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # hexrgba
29
+
30
+ Parse, validate and normalize CSS hex colors and named colors, with exact alpha handling,
31
+ CSS `rgb()` / `rgba()` output, WCAG contrast and alpha blending. Typed, zero dependencies.
32
+
33
+ ## Install
34
+
35
+ ```
36
+ pip install hexrgba
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from hexrgba import Color, contrast_ratio, is_hex, normalize, parse, parse_color, to_hex
43
+
44
+ is_hex("#f00") # True
45
+ parse("#ff000080") # (255, 0, 0, 128)
46
+ normalize("#F00") # "#ff0000"
47
+ normalize("#AABBCC", short=True) # "#abc"
48
+ to_hex(255, 0, 128, 64) # "#ff008040"
49
+
50
+ color = parse_color("RebeccaPurple") # Color(r=102, g=51, b=153, a=255)
51
+ color.hex # "#663399"
52
+ color.to_css() # "rgb(102, 51, 153)"
53
+ parse_color("#ff00ffed").to_css() # "rgba(255, 0, 255, 0.93)"
54
+ parse_color("#00ffff").name # "aqua"
55
+ Color.from_rgba(255, 0, 0, 0.5).hex # "#ff000080"
56
+
57
+ contrast_ratio("#767676", "white") # 4.54...
58
+ ```
59
+
60
+ What it covers:
61
+
62
+ - **All four hex forms** (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) and **all 148 CSS named
63
+ colors** plus `transparent`, matched ASCII case-insensitively as CSS requires.
64
+ - **Strict or lenient.** `strict=True` follows the CSS grammar (leading `#`, no surrounding
65
+ whitespace). The default also accepts a missing `#` and strips surrounding
66
+ Python whitespace (`str.strip()`).
67
+ - **A frozen `Color`** with `hex`, `to_hex(short=True)`, `to_css()`, `name`, `alpha`,
68
+ `with_alpha()` and `from_rgba()`.
69
+ - **Exact alpha.** `alpha_to_byte` and `css_alpha` follow CSS Color 4; every alpha byte
70
+ round-trips through its 0..1 float and through its CSS string.
71
+ - **WCAG 2.x** `relative_luminance`, `contrast_ratio` and `passes_contrast` (AA / AAA).
72
+ - **Blending** of a translucent color over a background with `blend`.
73
+ - **Typed errors**, all `ValueError`: `ColorError`, `HexColorError`, `ColorNameError`,
74
+ `ChannelError`.
75
+
76
+ The full guide, with the exact rules and the sources they come from, is in
77
+ [GUIDE.md](GUIDE.md).
78
+
79
+ ## Command line
80
+
81
+ ```
82
+ hexrgba normalize "#FFAA00" # #ffaa00
83
+ hexrgba normalize "#aabbcc" --short # #abc
84
+ hexrgba convert "#ff00ffed" --to css # rgba(255, 0, 255, 0.93)
85
+ hexrgba convert "#00ffff" --to name # aqua
86
+ hexrgba contrast "#767676" white # 4.54:1
87
+ hexrgba contrast "#777" white --level AA # prints 4.47:1, exits 1
88
+ hexrgba blend "#0000ff80" red # #7f0080
89
+ ```
90
+
91
+ Quote a leading `#`: most shells read it as the start of a comment. Errors go to stderr.
92
+ Exit codes: 0 success, 1 the pair fails `--level`, 2 bad usage or an invalid color. `python -m hexrgba` works too.
93
+
94
+ ## Why
95
+
96
+ `webcolors` is built around the CSS named-color tables, and most other color packages pull
97
+ in a wider model of color spaces. `hexrgba` stays small and exact: the hex and keyword
98
+ syntax of CSS, checked against the spec tables and the Web Platform Tests vectors, an
99
+ alpha conversion that never drifts, and the WCAG contrast math, with no dependencies.
100
+
101
+ ## License
102
+
103
+ MIT. See [LICENSE](LICENSE).
104
+
105
+ ## Links
106
+
107
+ - Website: https://lianes8studio.com
108
+ - GitHub: https://github.com/JaviLianes8
109
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,82 @@
1
+ # hexrgba
2
+
3
+ Parse, validate and normalize CSS hex colors and named colors, with exact alpha handling,
4
+ CSS `rgb()` / `rgba()` output, WCAG contrast and alpha blending. Typed, zero dependencies.
5
+
6
+ ## Install
7
+
8
+ ```
9
+ pip install hexrgba
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```python
15
+ from hexrgba import Color, contrast_ratio, is_hex, normalize, parse, parse_color, to_hex
16
+
17
+ is_hex("#f00") # True
18
+ parse("#ff000080") # (255, 0, 0, 128)
19
+ normalize("#F00") # "#ff0000"
20
+ normalize("#AABBCC", short=True) # "#abc"
21
+ to_hex(255, 0, 128, 64) # "#ff008040"
22
+
23
+ color = parse_color("RebeccaPurple") # Color(r=102, g=51, b=153, a=255)
24
+ color.hex # "#663399"
25
+ color.to_css() # "rgb(102, 51, 153)"
26
+ parse_color("#ff00ffed").to_css() # "rgba(255, 0, 255, 0.93)"
27
+ parse_color("#00ffff").name # "aqua"
28
+ Color.from_rgba(255, 0, 0, 0.5).hex # "#ff000080"
29
+
30
+ contrast_ratio("#767676", "white") # 4.54...
31
+ ```
32
+
33
+ What it covers:
34
+
35
+ - **All four hex forms** (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) and **all 148 CSS named
36
+ colors** plus `transparent`, matched ASCII case-insensitively as CSS requires.
37
+ - **Strict or lenient.** `strict=True` follows the CSS grammar (leading `#`, no surrounding
38
+ whitespace). The default also accepts a missing `#` and strips surrounding
39
+ Python whitespace (`str.strip()`).
40
+ - **A frozen `Color`** with `hex`, `to_hex(short=True)`, `to_css()`, `name`, `alpha`,
41
+ `with_alpha()` and `from_rgba()`.
42
+ - **Exact alpha.** `alpha_to_byte` and `css_alpha` follow CSS Color 4; every alpha byte
43
+ round-trips through its 0..1 float and through its CSS string.
44
+ - **WCAG 2.x** `relative_luminance`, `contrast_ratio` and `passes_contrast` (AA / AAA).
45
+ - **Blending** of a translucent color over a background with `blend`.
46
+ - **Typed errors**, all `ValueError`: `ColorError`, `HexColorError`, `ColorNameError`,
47
+ `ChannelError`.
48
+
49
+ The full guide, with the exact rules and the sources they come from, is in
50
+ [GUIDE.md](GUIDE.md).
51
+
52
+ ## Command line
53
+
54
+ ```
55
+ hexrgba normalize "#FFAA00" # #ffaa00
56
+ hexrgba normalize "#aabbcc" --short # #abc
57
+ hexrgba convert "#ff00ffed" --to css # rgba(255, 0, 255, 0.93)
58
+ hexrgba convert "#00ffff" --to name # aqua
59
+ hexrgba contrast "#767676" white # 4.54:1
60
+ hexrgba contrast "#777" white --level AA # prints 4.47:1, exits 1
61
+ hexrgba blend "#0000ff80" red # #7f0080
62
+ ```
63
+
64
+ Quote a leading `#`: most shells read it as the start of a comment. Errors go to stderr.
65
+ Exit codes: 0 success, 1 the pair fails `--level`, 2 bad usage or an invalid color. `python -m hexrgba` works too.
66
+
67
+ ## Why
68
+
69
+ `webcolors` is built around the CSS named-color tables, and most other color packages pull
70
+ in a wider model of color spaces. `hexrgba` stays small and exact: the hex and keyword
71
+ syntax of CSS, checked against the spec tables and the Web Platform Tests vectors, an
72
+ alpha conversion that never drifts, and the WCAG contrast math, with no dependencies.
73
+
74
+ ## License
75
+
76
+ MIT. See [LICENSE](LICENSE).
77
+
78
+ ## Links
79
+
80
+ - Website: https://lianes8studio.com
81
+ - GitHub: https://github.com/JaviLianes8
82
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hexrgba"
7
+ version = "0.2.1"
8
+ description = "CSS hex and named colors: parse, normalize, exact alpha, rgba(), WCAG contrast and blending"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = [
15
+ "hex", "color", "css", "rgba", "parse", "normalize", "named-colors", "wcag", "contrast", "blend",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.scripts]
26
+ hexrgba = "hexrgba._cli:main"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://lianes8studio.com"
33
+ Repository = "https://github.com/JaviLianes8/hexrgba"
34
+ Issues = "https://github.com/JaviLianes8/hexrgba/issues"
35
+ Changelog = "https://github.com/JaviLianes8/hexrgba/blob/main/CHANGELOG.md"
36
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
37
+ Funding = "https://buymeacoffee.com/jlianesglrs"
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ # Third-party test files are fetched locally and never shipped; only their manifest is.
41
+ include = [
42
+ "src/hexrgba",
43
+ "tests/*.py",
44
+ "tests/fixtures/css-color-4-named-colors.json",
45
+ "tests/upstream/MANIFEST.json",
46
+ "scripts/fetch_upstream.py",
47
+ "scripts/e2e_wheel.py",
48
+ "CHANGELOG.md",
49
+ "GUIDE.md",
50
+ "README.md",
51
+ "LICENSE",
52
+ "pyproject.toml",
53
+ ]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/hexrgba"]
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py310"
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
64
+
65
+ [tool.mypy]
66
+ python_version = "3.10"
67
+ strict = true
68
+
69
+ [tool.pytest.ini_options]
70
+ testpaths = ["tests"]
71
+ pythonpath = ["src"]
72
+ addopts = "-q"
@@ -0,0 +1,106 @@
1
+ """Build the wheel, install it in a throwaway venv and drive the installed command.
2
+
3
+ Run from anywhere: ``python scripts/e2e_wheel.py``. Exits 0 when every check passes.
4
+ Needs pip and network access for the build backend. Every example printed by
5
+ ``hexrgba --help`` is executed against the installed script and must hold.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shlex
12
+ import subprocess
13
+ import sys
14
+ import tarfile
15
+ import tempfile
16
+ import zipfile
17
+ from pathlib import Path
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+
21
+
22
+ def run(cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str, str]:
23
+ done = subprocess.run(cmd, capture_output=True, cwd=cwd, env=env, check=False, timeout=600)
24
+ return done.returncode, done.stdout.decode("utf-8"), done.stderr.decode("utf-8")
25
+
26
+
27
+ def check(label: str, condition: bool, detail: object = "") -> None:
28
+ print(("ok " if condition else "FAIL ") + label + ("" if condition else f": {detail!r}"))
29
+ if not condition:
30
+ raise SystemExit(1)
31
+
32
+
33
+ def help_examples(help_text: str) -> list[tuple[list[str], int, str]]:
34
+ """Return (argv, exit code, stdout) for every ``hexrgba ... -> ...`` help line."""
35
+ examples = []
36
+ for line in help_text.splitlines():
37
+ if not line.startswith(" hexrgba ") or "->" not in line:
38
+ continue
39
+ command, expected = (part.strip() for part in line.split("->", 1))
40
+ code = 0
41
+ if expected.startswith("exits 1:"):
42
+ code, expected = 1, expected.removeprefix("exits 1:").split()[0]
43
+ examples.append((shlex.split(command)[1:], code, expected))
44
+ return examples
45
+
46
+
47
+ def main() -> int:
48
+ with tempfile.TemporaryDirectory(prefix="hexrgba-e2e-") as raw:
49
+ work = Path(raw)
50
+ dist = work / "dist"
51
+ code, _, err = run(
52
+ [sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", str(dist), str(ROOT)],
53
+ cwd=work,
54
+ )
55
+ check("wheel builds", code == 0, err[-2000:])
56
+ wheel = next(dist.glob("hexrgba-*.whl"))
57
+ names = zipfile.ZipFile(wheel).namelist()
58
+ check("wheel ships py.typed", "hexrgba/py.typed" in names, names)
59
+ check("wheel has no tests", not any(n.startswith(("tests/", "scripts/")) for n in names))
60
+ entry = [n for n in names if n.endswith("entry_points.txt")]
61
+ text = zipfile.ZipFile(wheel).read(entry[0]).decode() if entry else ""
62
+ check("console script declared", "hexrgba = hexrgba._cli:main" in text, text)
63
+
64
+ code, _, err = run(
65
+ [sys.executable, "-m", "build", "--sdist", "-o", str(dist), "."], cwd=ROOT
66
+ )
67
+ check("sdist builds", code == 0, err[-2000:])
68
+ with tarfile.open(next(dist.glob("hexrgba-*.tar.gz"))) as sdist:
69
+ members = [n.split("/", 1)[-1] for n in sdist.getnames()]
70
+ check("sdist ships the manifest", "tests/upstream/MANIFEST.json" in members, members)
71
+ check("sdist has no WPT page", not any(n.endswith(".html") for n in members), members)
72
+
73
+ venv = work / "venv"
74
+ check("venv created", run([sys.executable, "-m", "venv", str(venv)], cwd=work)[0] == 0)
75
+ bindir = venv / ("Scripts" if os.name == "nt" else "bin")
76
+ python = str(bindir / "python")
77
+ code, _, err = run(
78
+ [python, "-m", "pip", "install", "--no-index", "--no-deps", str(wheel)], cwd=work
79
+ )
80
+ check("wheel installs", code == 0, err[-2000:])
81
+ tool = str(bindir / "hexrgba")
82
+ env = {"PATH": os.environ.get("PATH", ""), "SYSTEMROOT": os.environ.get("SYSTEMROOT", "")}
83
+
84
+ code, out, _ = run([tool, "--version"], cwd=work, env=env)
85
+ check("--version", code == 0 and out.strip().startswith("hexrgba 0.2"), out)
86
+ code, out, _ = run([tool, "--help"], cwd=work, env=env)
87
+ check("--help shows examples", code == 0 and "examples" in out, out)
88
+ examples = help_examples(out)
89
+ check("help has examples", len(examples) >= 8, examples)
90
+ for argv, want_code, want_out in examples:
91
+ code, got, _ = run([tool, *argv], cwd=work, env=env)
92
+ check(f"example {' '.join(argv)}", (code, got.strip()) == (want_code, want_out), got)
93
+
94
+ code, out, err = run([tool, "normalize", "nope"], cwd=work, env=env)
95
+ check("bad input exits 2 on stderr", (code, out) == (2, "") and "error" in err, err)
96
+ code, out, _ = run([python, "-m", "hexrgba", "normalize", "BLACK"], cwd=work, env=env)
97
+ check("python -m hexrgba", (code, out.strip()) == (0, "#000000"), out)
98
+ probe = "import hexrgba; print(hexrgba.Color.parse('rebeccapurple').to_css())"
99
+ code, out, _ = run([python, "-c", probe], cwd=work, env=env)
100
+ check("library import", out.strip() == "rgb(102, 51, 153)", out)
101
+ print("e2e: all checks passed")
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
@@ -0,0 +1,37 @@
1
+ """Fetch the third-party test files listed in tests/upstream/MANIFEST.json.
2
+
3
+ The files are not part of the repository. Their tests skip until this script has fetched
4
+ them; each download is checked against the size and sha256 in the manifest.
5
+
6
+ python scripts/fetch_upstream.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sys
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ FOLDER = Path(__file__).resolve().parents[1] / "tests" / "upstream"
18
+
19
+
20
+ def main() -> int:
21
+ manifest = json.loads((FOLDER / "MANIFEST.json").read_text(encoding="utf-8"))
22
+ failed = 0
23
+ for name, entry in manifest.items():
24
+ with urllib.request.urlopen(entry["url"], timeout=60) as response:
25
+ body = response.read()
26
+ digest = hashlib.sha256(body).hexdigest()
27
+ if len(body) != entry["size"] or digest != entry["sha256"]:
28
+ print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
29
+ failed += 1
30
+ continue
31
+ (FOLDER / name).write_bytes(body)
32
+ print(f"{name}: ok")
33
+ return 1 if failed else 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ sys.exit(main())
@@ -0,0 +1,36 @@
1
+ """Parse, validate and normalize CSS hex colors and named colors, with exact alpha,
2
+ CSS serialization, WCAG contrast and alpha blending."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from ._alpha import alpha_to_byte, css_alpha
7
+ from ._blend import blend
8
+ from ._color import Color, is_color, parse_color
9
+ from ._errors import ChannelError, ColorError, ColorNameError, HexColorError
10
+ from ._hex import is_hex, normalize, parse, shorten, to_hex
11
+ from ._names import NAMED_COLORS
12
+ from ._wcag import contrast_ratio, passes_contrast, relative_luminance
13
+
14
+ __version__ = "0.2.1"
15
+
16
+ __all__ = [
17
+ "NAMED_COLORS",
18
+ "ChannelError",
19
+ "Color",
20
+ "ColorError",
21
+ "ColorNameError",
22
+ "HexColorError",
23
+ "alpha_to_byte",
24
+ "blend",
25
+ "contrast_ratio",
26
+ "css_alpha",
27
+ "is_color",
28
+ "is_hex",
29
+ "normalize",
30
+ "parse",
31
+ "parse_color",
32
+ "passes_contrast",
33
+ "relative_luminance",
34
+ "shorten",
35
+ "to_hex",
36
+ ]