collect-framework-fox-task5 1.0.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.
- collect_framework_fox_task5-1.0.0/.claude/settings.local.json +7 -0
- collect_framework_fox_task5-1.0.0/.gitignore +7 -0
- collect_framework_fox_task5-1.0.0/PKG-INFO +5 -0
- collect_framework_fox_task5-1.0.0/README.md +54 -0
- collect_framework_fox_task5-1.0.0/pyproject.toml +15 -0
- collect_framework_fox_task5-1.0.0/src/collect_framework/__init__.py +10 -0
- collect_framework_fox_task5-1.0.0/src/collect_framework/__main__.py +43 -0
- collect_framework_fox_task5-1.0.0/tests/test_collect_framework.py +117 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# collect_framework
|
|
2
|
+
|
|
3
|
+
A CLI tool that counts unique characters (characters appearing exactly once) in a string or text file.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For development (editable install):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Count unique characters in a string
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m collect_framework --string "abbbccdf"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Output: `3` (characters `a`, `d`, `f` appear once)
|
|
26
|
+
|
|
27
|
+
### Count unique characters in a file
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m collect_framework --file path_to_text_file
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Both parameters
|
|
34
|
+
|
|
35
|
+
If both `--string` and `--file` are provided, `--file` takes priority and the string is ignored:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m collect_framework --string "your string" --file path_to_text_file
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### As an installed script
|
|
42
|
+
|
|
43
|
+
After installation, the command is also available directly:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
collect_framework --string "hello"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Running Tests
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install pytest
|
|
53
|
+
python -m pytest tests/ -v
|
|
54
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "collect-framework-fox-task5"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Count unique characters in a string via CLI"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
collect_framework = "collect_framework.__main__:main"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/collect_framework"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Count unique characters in a string."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@lru_cache
|
|
8
|
+
def count_elements(string: str) -> int:
|
|
9
|
+
"""Return the number of characters that appear exactly once."""
|
|
10
|
+
return sum(1 for count in Counter(string).values() if count == 1)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""CLI entry point for collect_framework."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from collect_framework import count_elements
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
9
|
+
parser = argparse.ArgumentParser(description="Count unique characters.")
|
|
10
|
+
parser.add_argument("--string", nargs="+", help="Input string")
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--file",
|
|
13
|
+
nargs="+",
|
|
14
|
+
help="Path to text file(s) (higher priority than --string)",
|
|
15
|
+
)
|
|
16
|
+
return parser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(args=None):
|
|
20
|
+
parser = create_parser()
|
|
21
|
+
parsed = parser.parse_args(args)
|
|
22
|
+
|
|
23
|
+
if parsed.file:
|
|
24
|
+
parts = []
|
|
25
|
+
for filepath in parsed.file:
|
|
26
|
+
try:
|
|
27
|
+
with open(filepath, "r") as f:
|
|
28
|
+
parts.append(f.read())
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
parser.error(f"File not found: {filepath}")
|
|
31
|
+
text = "".join(parts)
|
|
32
|
+
elif parsed.string is not None:
|
|
33
|
+
text = " ".join(parsed.string)
|
|
34
|
+
else:
|
|
35
|
+
parser.error("Provide --string or --file")
|
|
36
|
+
|
|
37
|
+
result = count_elements(text)
|
|
38
|
+
print(result)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tests for collect_framework package."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import patch, mock_open
|
|
5
|
+
|
|
6
|
+
from collect_framework import count_elements
|
|
7
|
+
from collect_framework.__main__ import main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize("input_str, expected", [
|
|
11
|
+
pytest.param("abbbccdf", 3, id="example_from_spec (a, d, f)"),
|
|
12
|
+
pytest.param("abcdef", 6, id="all_unique"),
|
|
13
|
+
pytest.param("aabbcc", 0, id="no_unique"),
|
|
14
|
+
pytest.param("a", 1, id="single_char"),
|
|
15
|
+
pytest.param("", 0, id="empty_string"),
|
|
16
|
+
pytest.param("aaaa", 0, id="all_same_char"),
|
|
17
|
+
pytest.param("AaBb", 4, id="mixed_case (A and a are different)"),
|
|
18
|
+
pytest.param("a b b", 1, id="spaces (a unique, space and b repeated)"),
|
|
19
|
+
pytest.param("112233", 0, id="digits_no_unique"),
|
|
20
|
+
pytest.param("12345", 5, id="digits_all_unique"),
|
|
21
|
+
pytest.param("café", 4, id="unicode (c, a, f, é all unique)"),
|
|
22
|
+
pytest.param("x", 1, id="single_x"),
|
|
23
|
+
pytest.param("xx", 0, id="double_x"),
|
|
24
|
+
pytest.param("abcabc", 0, id="repeated_pattern"),
|
|
25
|
+
pytest.param("abcabcd", 1, id="one_unique_in_pattern"),
|
|
26
|
+
pytest.param("aAbBcC", 6, id="mixed_case_all_unique"),
|
|
27
|
+
pytest.param("!@#!@#$", 1, id="special_chars_one_unique"),
|
|
28
|
+
])
|
|
29
|
+
def test_count_elements(input_str, expected):
|
|
30
|
+
assert count_elements(input_str) == expected
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestCaching:
|
|
34
|
+
|
|
35
|
+
def setup_method(self):
|
|
36
|
+
count_elements.cache_clear()
|
|
37
|
+
|
|
38
|
+
def test_cache_returns_same_result(self):
|
|
39
|
+
first_call = count_elements("abbbccdf")
|
|
40
|
+
second_call = count_elements("abbbccdf")
|
|
41
|
+
assert first_call == second_call
|
|
42
|
+
|
|
43
|
+
def test_cache_stats_show_hit(self):
|
|
44
|
+
count_elements("hello")
|
|
45
|
+
count_elements("hello")
|
|
46
|
+
info = count_elements.cache_info()
|
|
47
|
+
assert info.hits == 1
|
|
48
|
+
assert info.misses == 1
|
|
49
|
+
|
|
50
|
+
def test_different_strings_cached_separately(self):
|
|
51
|
+
count_elements("abc")
|
|
52
|
+
count_elements("def")
|
|
53
|
+
info = count_elements.cache_info()
|
|
54
|
+
assert info.misses == 2
|
|
55
|
+
assert info.currsize == 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestCLIArgs:
|
|
59
|
+
"""Test argparse parameter handling with mocked arguments."""
|
|
60
|
+
|
|
61
|
+
def test_string_single_word(self, capsys):
|
|
62
|
+
result = main(["--string", "abbbccdf"])
|
|
63
|
+
assert result == 3
|
|
64
|
+
assert capsys.readouterr().out.strip() == "3"
|
|
65
|
+
|
|
66
|
+
def test_string_multiple_words(self, capsys):
|
|
67
|
+
result = main(["--string", "your", "string"])
|
|
68
|
+
# "your string" joined with space → count unique chars
|
|
69
|
+
assert result == count_elements("your string")
|
|
70
|
+
|
|
71
|
+
def test_file_flag(self, capsys):
|
|
72
|
+
m = mock_open(read_data="abcdef")
|
|
73
|
+
with patch("builtins.open", m):
|
|
74
|
+
result = main(["--file", "dummy.txt"])
|
|
75
|
+
m.assert_called_once_with("dummy.txt", "r")
|
|
76
|
+
assert result == 6
|
|
77
|
+
assert capsys.readouterr().out.strip() == "6"
|
|
78
|
+
|
|
79
|
+
def test_file_has_priority_over_string(self, capsys):
|
|
80
|
+
m = mock_open(read_data="xyz")
|
|
81
|
+
with patch("builtins.open", m):
|
|
82
|
+
result = main(["--string", "abbbccdf", "--file", "dummy.txt"])
|
|
83
|
+
m.assert_called_once_with("dummy.txt", "r")
|
|
84
|
+
assert result == 3 # x, y, z all unique
|
|
85
|
+
assert capsys.readouterr().out.strip() == "3"
|
|
86
|
+
|
|
87
|
+
def test_multiple_files(self, capsys):
|
|
88
|
+
m = mock_open(read_data="abc")
|
|
89
|
+
with patch("builtins.open", m):
|
|
90
|
+
result = main(["--file", "a.txt", "b.txt"])
|
|
91
|
+
assert m.call_count == 2
|
|
92
|
+
|
|
93
|
+
def test_no_args_raises_error(self):
|
|
94
|
+
with pytest.raises(SystemExit):
|
|
95
|
+
main([])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestFileReading:
|
|
99
|
+
"""Test file reading with mocks."""
|
|
100
|
+
|
|
101
|
+
def test_reads_file_content(self, capsys):
|
|
102
|
+
m = mock_open(read_data="hello")
|
|
103
|
+
with patch("builtins.open", m):
|
|
104
|
+
result = main(["--file", "test.txt"])
|
|
105
|
+
m.assert_called_once_with("test.txt", "r")
|
|
106
|
+
assert result == 3 # h, e, o unique; l repeated
|
|
107
|
+
|
|
108
|
+
def test_missing_file_raises_error(self):
|
|
109
|
+
with patch("builtins.open", side_effect=FileNotFoundError):
|
|
110
|
+
with pytest.raises(SystemExit):
|
|
111
|
+
main(["--file", "nonexistent.txt"])
|
|
112
|
+
|
|
113
|
+
def test_empty_file(self, capsys):
|
|
114
|
+
m = mock_open(read_data="")
|
|
115
|
+
with patch("builtins.open", m):
|
|
116
|
+
result = main(["--file", "empty.txt"])
|
|
117
|
+
assert result == 0
|