student-email-tools 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MinhThang1009
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.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: student-email-tools
3
+ Version: 0.2.0
4
+ Summary: Python tools for generating and formatting student email lists.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: openpyxl>=3.1
9
+ Requires-Dist: pandas>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.13; extra == "dev"
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.9; extra == "dev"
14
+ Provides-Extra: xls
15
+ Requires-Dist: xlrd>=2.0; extra == "xls"
16
+ Dynamic: license-file
17
+
18
+ <div align="center">
19
+
20
+ # student-email-tools
21
+
22
+ Python tools for generating and formatting student email lists from local files.
23
+
24
+ [![CI](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml)
25
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
+
27
+ </div>
28
+
29
+ ## Table of Contents
30
+
31
+ - [1. Overview](#1-overview)
32
+ - [2. Requirements](#2-requirements)
33
+ - [3. Installation](#3-installation)
34
+ - [4. Usage](#4-usage)
35
+ - [4.1 Generate emails from Excel](#41-generate-emails-from-excel)
36
+ - [4.2 Format TXT files](#42-format-txt-files)
37
+ - [5. Development](#5-development)
38
+ - [6. Contributing and support](#6-contributing-and-support)
39
+ - [7. Releases](#7-releases)
40
+ - [8. License](#8-license)
41
+
42
+ ## 1. Overview
43
+
44
+ This project provides two commands:
45
+
46
+ - Generate email addresses from the `First name` and `Last name` columns in an
47
+ Excel file.
48
+ - Split a TXT file into spaced line blocks that are easy to copy and send.
49
+
50
+ Excel files, email lists, virtual environments, and caches are local data; they
51
+ do not belong in this repository.
52
+
53
+ ## 2. Requirements
54
+
55
+ - Python 3.10 or newer.
56
+ - `pandas` and `openpyxl` for `.xlsx` files.
57
+ - Install the `xls` extra when `.xls` support is needed.
58
+
59
+ ## 3. Installation
60
+
61
+ ```powershell
62
+ python -m venv .venv
63
+ ./.venv/Scripts/python.exe -m pip install -e ".[dev]"
64
+ ```
65
+
66
+ For `.xls` support:
67
+
68
+ ```powershell
69
+ ./.venv/Scripts/python.exe -m pip install -e ".[xls]"
70
+ ```
71
+
72
+ ## 4. Usage
73
+
74
+ ### 4.1 Generate emails from Excel
75
+
76
+ Place Excel files in a dedicated directory and run:
77
+
78
+ ```powershell
79
+ python emails.py "D:/data/course"
80
+ ```
81
+
82
+ Or use the entry point after installing the package:
83
+
84
+ ```powershell
85
+ generate-emails "D:/data/course"
86
+ ```
87
+
88
+ Each Excel file produces a TXT file with the same basename. The parser handles
89
+ mixed code/name formats, Vietnamese diacritics, and invalid local-part
90
+ characters.
91
+
92
+ ### 4.2 Format TXT files
93
+
94
+ ```powershell
95
+ python 10lines.py "D:/data/emails.txt"
96
+ ```
97
+
98
+ The command creates an `_output.txt` file, with 500 lines per block and 10 blank
99
+ lines between blocks by default. Customize the layout with:
100
+
101
+ ```powershell
102
+ format-email-blocks "D:/data/emails.txt" --lines-per-block 100 --gap-lines 2
103
+ ```
104
+
105
+ ## 5. Development
106
+
107
+ ```powershell
108
+ python -m pytest -q
109
+ python -m ruff check email_tools emails.py 10lines.py scripts/ci_runtime.py tests
110
+ python -m ruff format --check email_tools emails.py 10lines.py scripts/ci_runtime.py tests
111
+ python -m mypy --ignore-missing-imports email_tools emails.py 10lines.py scripts/ci_runtime.py
112
+ ```
113
+
114
+ ## 6. Contributing and support
115
+
116
+ Read [CONTRIBUTING.md](CONTRIBUTING.md), [SUPPORT.md](SUPPORT.md), and
117
+ [SECURITY.md](SECURITY.md). Do not commit real student data or email lists.
118
+
119
+ ## 7. Releases
120
+
121
+ Release Please creates the release pull request and GitHub Release. The release
122
+ workflow also builds source and wheel distributions and publishes them to PyPI
123
+ using Trusted Publishing.
124
+
125
+ Before the first package release, configure a PyPI Trusted Publisher with:
126
+
127
+ - Owner: `MinhThang1009`
128
+ - Repository: `student-email-tools`
129
+ - Workflow: `.github/workflows/release.yml`
130
+ - GitHub environment: `pypi`
131
+
132
+ See the [PyPI Trusted Publishers guide](https://docs.pypi.org/trusted-publishers/).
133
+
134
+ ## 8. License
135
+
136
+ This project is released under the [MIT License](LICENSE).
@@ -0,0 +1,119 @@
1
+ <div align="center">
2
+
3
+ # student-email-tools
4
+
5
+ Python tools for generating and formatting student email lists from local files.
6
+
7
+ [![CI](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
+
10
+ </div>
11
+
12
+ ## Table of Contents
13
+
14
+ - [1. Overview](#1-overview)
15
+ - [2. Requirements](#2-requirements)
16
+ - [3. Installation](#3-installation)
17
+ - [4. Usage](#4-usage)
18
+ - [4.1 Generate emails from Excel](#41-generate-emails-from-excel)
19
+ - [4.2 Format TXT files](#42-format-txt-files)
20
+ - [5. Development](#5-development)
21
+ - [6. Contributing and support](#6-contributing-and-support)
22
+ - [7. Releases](#7-releases)
23
+ - [8. License](#8-license)
24
+
25
+ ## 1. Overview
26
+
27
+ This project provides two commands:
28
+
29
+ - Generate email addresses from the `First name` and `Last name` columns in an
30
+ Excel file.
31
+ - Split a TXT file into spaced line blocks that are easy to copy and send.
32
+
33
+ Excel files, email lists, virtual environments, and caches are local data; they
34
+ do not belong in this repository.
35
+
36
+ ## 2. Requirements
37
+
38
+ - Python 3.10 or newer.
39
+ - `pandas` and `openpyxl` for `.xlsx` files.
40
+ - Install the `xls` extra when `.xls` support is needed.
41
+
42
+ ## 3. Installation
43
+
44
+ ```powershell
45
+ python -m venv .venv
46
+ ./.venv/Scripts/python.exe -m pip install -e ".[dev]"
47
+ ```
48
+
49
+ For `.xls` support:
50
+
51
+ ```powershell
52
+ ./.venv/Scripts/python.exe -m pip install -e ".[xls]"
53
+ ```
54
+
55
+ ## 4. Usage
56
+
57
+ ### 4.1 Generate emails from Excel
58
+
59
+ Place Excel files in a dedicated directory and run:
60
+
61
+ ```powershell
62
+ python emails.py "D:/data/course"
63
+ ```
64
+
65
+ Or use the entry point after installing the package:
66
+
67
+ ```powershell
68
+ generate-emails "D:/data/course"
69
+ ```
70
+
71
+ Each Excel file produces a TXT file with the same basename. The parser handles
72
+ mixed code/name formats, Vietnamese diacritics, and invalid local-part
73
+ characters.
74
+
75
+ ### 4.2 Format TXT files
76
+
77
+ ```powershell
78
+ python 10lines.py "D:/data/emails.txt"
79
+ ```
80
+
81
+ The command creates an `_output.txt` file, with 500 lines per block and 10 blank
82
+ lines between blocks by default. Customize the layout with:
83
+
84
+ ```powershell
85
+ format-email-blocks "D:/data/emails.txt" --lines-per-block 100 --gap-lines 2
86
+ ```
87
+
88
+ ## 5. Development
89
+
90
+ ```powershell
91
+ python -m pytest -q
92
+ python -m ruff check email_tools emails.py 10lines.py scripts/ci_runtime.py tests
93
+ python -m ruff format --check email_tools emails.py 10lines.py scripts/ci_runtime.py tests
94
+ python -m mypy --ignore-missing-imports email_tools emails.py 10lines.py scripts/ci_runtime.py
95
+ ```
96
+
97
+ ## 6. Contributing and support
98
+
99
+ Read [CONTRIBUTING.md](CONTRIBUTING.md), [SUPPORT.md](SUPPORT.md), and
100
+ [SECURITY.md](SECURITY.md). Do not commit real student data or email lists.
101
+
102
+ ## 7. Releases
103
+
104
+ Release Please creates the release pull request and GitHub Release. The release
105
+ workflow also builds source and wheel distributions and publishes them to PyPI
106
+ using Trusted Publishing.
107
+
108
+ Before the first package release, configure a PyPI Trusted Publisher with:
109
+
110
+ - Owner: `MinhThang1009`
111
+ - Repository: `student-email-tools`
112
+ - Workflow: `.github/workflows/release.yml`
113
+ - GitHub environment: `pypi`
114
+
115
+ See the [PyPI Trusted Publishers guide](https://docs.pypi.org/trusted-publishers/).
116
+
117
+ ## 8. License
118
+
119
+ This project is released under the [MIT License](LICENSE).
@@ -0,0 +1,5 @@
1
+ """Utilities for generating and formatting participant email lists."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,291 @@
1
+ """Generate university email addresses from participant Excel files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import unicodedata
8
+ from collections.abc import Iterable, Sequence
9
+ from pathlib import Path
10
+
11
+ import pandas as pd
12
+
13
+ EMAIL_DOMAIN = "vanlanguni.vn"
14
+ EXCEL_SUFFIXES = {".xls", ".xlsx"}
15
+ LONG_FIRST_PREFIX_LENGTH = 14
16
+ EMAILS_PER_BLOCK = 500
17
+ BLANK_LINES_PER_BLOCK = 5
18
+
19
+ PREFIX_SEPARATOR = re.compile(r"\s*-\s*")
20
+ INVALID_LOCAL_PART_CHARS = re.compile(r"[^a-z0-9]")
21
+ NUMBER_AFTER_DOT = re.compile(r"^[a-z0-9]+\.([0-9]+)[a-z0-9]*@[^@]+$")
22
+
23
+
24
+ def extract_component(
25
+ value: object, *, leading_hyphen_uses_last_word: bool = False
26
+ ) -> str | None:
27
+ """Extract a usable component from a value with an optional hyphen."""
28
+ if value is None or pd.isna(value):
29
+ return None
30
+
31
+ text = str(value).strip()
32
+ if "-" not in text:
33
+ return text or None
34
+
35
+ parts = PREFIX_SEPARATOR.split(text, maxsplit=1)
36
+ prefix = parts[0].strip()
37
+ if prefix:
38
+ return prefix
39
+
40
+ if not leading_hyphen_uses_last_word or len(parts) == 1:
41
+ return None
42
+
43
+ suffix = parts[1].strip()
44
+ # A leading-hyphen value containing digits is a class/code value (for
45
+ # example, "- 71K31DUOC01"), not a person's name.
46
+ if not suffix or any(character.isdigit() for character in suffix):
47
+ return None
48
+ return suffix.split()[-1]
49
+
50
+
51
+ def extract_suffix(value: object) -> str | None:
52
+ """Return the trimmed text after the first hyphen, when present."""
53
+ if value is None or pd.isna(value):
54
+ return None
55
+
56
+ text = str(value).strip()
57
+ parts = PREFIX_SEPARATOR.split(text, maxsplit=1)
58
+ if len(parts) == 1:
59
+ return None
60
+ suffix = parts[1].strip()
61
+ return suffix or None
62
+
63
+
64
+ def extract_name_part(value: object) -> str | None:
65
+ """Return the name portion after an identifier and before class data."""
66
+ suffix = extract_suffix(value)
67
+ if suffix is None:
68
+ return None
69
+
70
+ name_part = PREFIX_SEPARATOR.split(suffix, maxsplit=1)[0].strip()
71
+ return name_part or None
72
+
73
+
74
+ def has_hyphen(value: object) -> bool:
75
+ """Return whether a non-empty source value contains a hyphen."""
76
+ if value is None or pd.isna(value):
77
+ return False
78
+ return "-" in str(value)
79
+
80
+
81
+ def extract_last_component(value: object) -> str | None:
82
+ """Extract the last-name component, including fallback name formats."""
83
+ return extract_component(value, leading_hyphen_uses_last_word=True)
84
+
85
+
86
+ def extract_last_component_from_row(
87
+ first_name: object, last_name: object
88
+ ) -> str | None:
89
+ """Resolve the last-name component across the source's mixed formats."""
90
+ last_component = extract_last_component(last_name)
91
+ if last_component is not None:
92
+ return last_component
93
+
94
+ if (
95
+ last_name is None
96
+ or pd.isna(last_name)
97
+ or not str(last_name).strip().startswith("-")
98
+ ):
99
+ return None
100
+
101
+ full_name = extract_suffix(first_name)
102
+ if full_name is None:
103
+ return None
104
+
105
+ # A second separator may carry a class code in the First name column.
106
+ name_part = PREFIX_SEPARATOR.split(full_name, maxsplit=1)[0].strip()
107
+ if not name_part or any(character.isdigit() for character in name_part):
108
+ return None
109
+ return name_part.split()[-1]
110
+
111
+
112
+ def normalize_local_part(prefix: object) -> str | None:
113
+ """Convert a name or identifier prefix to a safe ASCII email component."""
114
+ if prefix is None or pd.isna(prefix):
115
+ return None
116
+
117
+ # NFKD handles Vietnamese combining marks. Đ/đ do not decompose, so map
118
+ # them explicitly before converting the remaining text to ASCII.
119
+ transliterated = str(prefix).replace("Đ", "D").replace("đ", "d")
120
+ transliterated = unicodedata.normalize("NFKD", transliterated)
121
+ ascii_text = transliterated.encode("ascii", "ignore").decode("ascii").lower()
122
+ component = INVALID_LOCAL_PART_CHARS.sub("", ascii_text)
123
+ return component or None
124
+
125
+
126
+ def extract_number_after_dot(email: str) -> int | None:
127
+ """Return the leading number after the local-part dot, when present."""
128
+ match = NUMBER_AFTER_DOT.fullmatch(email)
129
+ return int(match.group(1)) if match else None
130
+
131
+
132
+ def email_sort_key(email: str) -> tuple[int, int, str]:
133
+ """Sort numbered local parts first and emails without one last."""
134
+ number = extract_number_after_dot(email)
135
+ if number is None:
136
+ return (1, 0, email)
137
+ return (0, number, email)
138
+
139
+
140
+ def normalize_required_columns(
141
+ dataframe: pd.DataFrame, source_name: str
142
+ ) -> pd.DataFrame:
143
+ """Rename required columns while tolerating header whitespace and case."""
144
+ required = ("first name", "last name")
145
+ matches: dict[str, list[object]] = {column: [] for column in required}
146
+
147
+ for column in dataframe.columns:
148
+ normalized = str(column).strip().casefold()
149
+ if normalized in matches:
150
+ matches[normalized].append(column)
151
+
152
+ missing = sorted(column for column, values in matches.items() if not values)
153
+ if missing:
154
+ raise ValueError(f"File Excel {source_name} không chứa các cột sau: {missing}")
155
+
156
+ duplicates = sorted(column for column, values in matches.items() if len(values) > 1)
157
+ if duplicates:
158
+ raise ValueError(f"File Excel {source_name} chứa cột bị trùng: {duplicates}")
159
+
160
+ rename_map = {matches[normalized][0]: normalized for normalized in required}
161
+ return dataframe.rename(columns=rename_map).copy()
162
+
163
+
164
+ def generate_emails(dataframe: pd.DataFrame, source_name: str = "<data>") -> list[str]:
165
+ """Generate and sort valid email addresses from a participant dataframe."""
166
+ dataframe = normalize_required_columns(dataframe, source_name)
167
+
168
+ dataframe["first_prefix"] = dataframe["first name"].map(extract_component)
169
+ dataframe["first_name_part"] = dataframe["first name"].map(extract_name_part)
170
+ dataframe["last_prefix"] = [
171
+ extract_last_component_from_row(first_name, last_name)
172
+ for first_name, last_name in zip(
173
+ dataframe["first name"], dataframe["last name"]
174
+ )
175
+ ]
176
+ dataframe["first_part"] = dataframe["first_prefix"].map(normalize_local_part)
177
+ dataframe["last_part"] = dataframe["last_prefix"].map(normalize_local_part)
178
+
179
+ valid_rows = dataframe["first_part"].notna() & dataframe["last_part"].notna()
180
+ dataframe = dataframe.loc[valid_rows].copy()
181
+
182
+ emails: list[str] = []
183
+ for first_prefix, first_name_part, first_part, last_part, last_name in zip(
184
+ dataframe["first_prefix"],
185
+ dataframe["first_name_part"],
186
+ dataframe["first_part"],
187
+ dataframe["last_part"],
188
+ dataframe["last name"],
189
+ ):
190
+ short_name_without_class = (
191
+ isinstance(first_name_part, str)
192
+ and len(first_name_part.split()) == 1
193
+ and not has_hyphen(last_name)
194
+ )
195
+ if len(first_prefix) >= LONG_FIRST_PREFIX_LENGTH or short_name_without_class:
196
+ emails.append(f"{first_part}@{EMAIL_DOMAIN}")
197
+ else:
198
+ emails.append(f"{last_part}.{first_part}@{EMAIL_DOMAIN}")
199
+
200
+ return sorted(emails, key=email_sort_key)
201
+
202
+
203
+ def find_excel_files(folder_path: Path | str) -> list[Path]:
204
+ """Find Excel files in a folder in deterministic, case-insensitive order."""
205
+ folder = Path(folder_path)
206
+ return sorted(
207
+ (
208
+ path
209
+ for path in folder.iterdir()
210
+ if path.is_file()
211
+ and path.suffix.casefold() in EXCEL_SUFFIXES
212
+ and not path.name.startswith("~$")
213
+ ),
214
+ key=lambda path: path.name.casefold(),
215
+ )
216
+
217
+
218
+ def write_emails(
219
+ emails: Iterable[str], output_path: Path, *, block_size: int = EMAILS_PER_BLOCK
220
+ ) -> None:
221
+ """Write one email per line with the requested block spacing."""
222
+ if block_size <= 0:
223
+ raise ValueError("block_size phải lớn hơn 0")
224
+
225
+ with output_path.open("w", encoding="utf-8", newline="") as output_file:
226
+ for index, email in enumerate(emails, start=1):
227
+ output_file.write(f"{email}\n")
228
+ if index % block_size == 0:
229
+ output_file.write("\n" * BLANK_LINES_PER_BLOCK)
230
+
231
+
232
+ def process_folder(folder_path: Path | str) -> list[Path]:
233
+ """Process every Excel file in ``folder_path`` and return output paths."""
234
+ folder = Path(folder_path).expanduser().resolve()
235
+ if not folder.is_dir():
236
+ raise FileNotFoundError(f"Không tìm thấy thư mục: {folder}")
237
+
238
+ excel_files = find_excel_files(folder)
239
+ if not excel_files:
240
+ raise FileNotFoundError(f"Không tìm thấy file Excel trong thư mục: {folder}")
241
+
242
+ output_paths: list[Path] = []
243
+ seen_outputs: set[str] = set()
244
+ for file_path in excel_files:
245
+ output_path = folder / f"{file_path.stem}.txt"
246
+ output_key = str(output_path).casefold()
247
+ if output_key in seen_outputs:
248
+ raise ValueError(f"Nhiều file Excel có cùng tên đầu ra: {output_path.name}")
249
+ seen_outputs.add(output_key)
250
+
251
+ dataframe = pd.read_excel(file_path, dtype=str)
252
+ emails = generate_emails(dataframe, file_path.name)
253
+ write_emails(emails, output_path)
254
+ output_paths.append(output_path)
255
+ skipped_rows = len(dataframe) - len(emails)
256
+ print(
257
+ f"Đã lưu {len(emails)} email từ file {file_path.name} "
258
+ f"(bỏ qua {skipped_rows} dòng không đủ dữ liệu)"
259
+ )
260
+
261
+ return output_paths
262
+
263
+
264
+ def parse_args(
265
+ argv: Sequence[str] | None = None, *, default_folder: Path | None = None
266
+ ) -> argparse.Namespace:
267
+ """Parse command-line arguments."""
268
+ parser = argparse.ArgumentParser(
269
+ description="Tạo danh sách email từ các file Excel người tham gia."
270
+ )
271
+ parser.add_argument(
272
+ "folder",
273
+ nargs="?",
274
+ type=Path,
275
+ default=default_folder or Path.cwd(),
276
+ help="Thư mục chứa file Excel.",
277
+ )
278
+ return parser.parse_args(argv)
279
+
280
+
281
+ def main(
282
+ argv: Sequence[str] | None = None, *, default_folder: Path | None = None
283
+ ) -> int:
284
+ """Run the email generation command."""
285
+ args = parse_args(argv, default_folder=default_folder)
286
+ process_folder(args.folder)
287
+ return 0
288
+
289
+
290
+ if __name__ == "__main__":
291
+ raise SystemExit(main())
@@ -0,0 +1,167 @@
1
+ """Format text files into fixed-size blocks separated by blank lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Iterable, Sequence
7
+ from pathlib import Path
8
+
9
+ DEFAULT_LINES_PER_BLOCK = 500
10
+ DEFAULT_GAP_LINES = 10
11
+ OUTPUT_SUFFIX = "_output"
12
+
13
+
14
+ def format_lines(
15
+ lines: Iterable[str],
16
+ *,
17
+ lines_per_block: int = DEFAULT_LINES_PER_BLOCK,
18
+ gap_lines: int = DEFAULT_GAP_LINES,
19
+ ) -> list[str]:
20
+ """Return lines grouped with blank lines between, but not after, blocks."""
21
+ if lines_per_block <= 0:
22
+ raise ValueError("lines_per_block phải lớn hơn 0")
23
+ if gap_lines < 0:
24
+ raise ValueError("gap_lines không được âm")
25
+
26
+ source_lines = list(lines)
27
+ formatted: list[str] = []
28
+ for start in range(0, len(source_lines), lines_per_block):
29
+ end = min(start + lines_per_block, len(source_lines))
30
+ formatted.extend(source_lines[start:end])
31
+ if end < len(source_lines):
32
+ formatted.extend([""] * gap_lines)
33
+ return formatted
34
+
35
+
36
+ def output_path_for(input_path: Path) -> Path:
37
+ """Return the conventional output path for a text input file."""
38
+ return input_path.with_name(f"{input_path.stem}{OUTPUT_SUFFIX}{input_path.suffix}")
39
+
40
+
41
+ def process_text_file(
42
+ input_path: Path | str,
43
+ output_path: Path | str | None = None,
44
+ *,
45
+ lines_per_block: int = DEFAULT_LINES_PER_BLOCK,
46
+ gap_lines: int = DEFAULT_GAP_LINES,
47
+ ) -> Path:
48
+ """Format one text file and return the generated output path."""
49
+ source = Path(input_path).expanduser().resolve()
50
+ if not source.is_file():
51
+ raise FileNotFoundError(f"Không tìm thấy file TXT: {source}")
52
+
53
+ destination = (
54
+ Path(output_path).expanduser().resolve()
55
+ if output_path is not None
56
+ else output_path_for(source)
57
+ )
58
+ if destination == source:
59
+ raise ValueError("File đầu ra phải khác file đầu vào")
60
+
61
+ lines = source.read_text(encoding="utf-8").splitlines()
62
+ formatted = format_lines(
63
+ lines, lines_per_block=lines_per_block, gap_lines=gap_lines
64
+ )
65
+ content = "\n".join(formatted)
66
+ if formatted:
67
+ content += "\n"
68
+ destination.write_text(content, encoding="utf-8", newline="")
69
+ return destination
70
+
71
+
72
+ def find_text_files(folder_path: Path | str) -> list[Path]:
73
+ """Find source TXT files without reprocessing generated outputs."""
74
+ folder = Path(folder_path)
75
+ return sorted(
76
+ (
77
+ path
78
+ for path in folder.iterdir()
79
+ if path.is_file()
80
+ and path.suffix.casefold() == ".txt"
81
+ and not path.name.startswith("~$")
82
+ and not path.stem.casefold().endswith(OUTPUT_SUFFIX.casefold())
83
+ ),
84
+ key=lambda path: path.name.casefold(),
85
+ )
86
+
87
+
88
+ def process_folder(
89
+ folder_path: Path | str,
90
+ *,
91
+ lines_per_block: int = DEFAULT_LINES_PER_BLOCK,
92
+ gap_lines: int = DEFAULT_GAP_LINES,
93
+ ) -> list[Path]:
94
+ """Format every source TXT file in a folder."""
95
+ folder = Path(folder_path).expanduser().resolve()
96
+ if not folder.is_dir():
97
+ raise FileNotFoundError(f"Không tìm thấy thư mục: {folder}")
98
+
99
+ text_files = find_text_files(folder)
100
+ if not text_files:
101
+ raise FileNotFoundError(f"Không tìm thấy file TXT trong thư mục: {folder}")
102
+
103
+ outputs = [
104
+ process_text_file(
105
+ path,
106
+ lines_per_block=lines_per_block,
107
+ gap_lines=gap_lines,
108
+ )
109
+ for path in text_files
110
+ ]
111
+ for source, destination in zip(text_files, outputs):
112
+ print(f"Đã xử lý {source.name}, lưu tại {destination.name}")
113
+ return outputs
114
+
115
+
116
+ def parse_args(
117
+ argv: Sequence[str] | None = None, *, default_folder: Path | None = None
118
+ ) -> argparse.Namespace:
119
+ """Parse command-line arguments."""
120
+ parser = argparse.ArgumentParser(
121
+ description="Chia file TXT thành các khối dòng có khoảng cách."
122
+ )
123
+ parser.add_argument(
124
+ "path",
125
+ nargs="?",
126
+ type=Path,
127
+ default=default_folder or Path.cwd(),
128
+ help="File TXT hoặc thư mục chứa file TXT.",
129
+ )
130
+ parser.add_argument(
131
+ "--lines-per-block",
132
+ type=int,
133
+ default=DEFAULT_LINES_PER_BLOCK,
134
+ help=f"Số dòng mỗi khối (mặc định: {DEFAULT_LINES_PER_BLOCK}).",
135
+ )
136
+ parser.add_argument(
137
+ "--gap-lines",
138
+ type=int,
139
+ default=DEFAULT_GAP_LINES,
140
+ help=f"Số dòng trống giữa các khối (mặc định: {DEFAULT_GAP_LINES}).",
141
+ )
142
+ return parser.parse_args(argv)
143
+
144
+
145
+ def main(
146
+ argv: Sequence[str] | None = None, *, default_folder: Path | None = None
147
+ ) -> int:
148
+ """Run the text-block formatting command."""
149
+ args = parse_args(argv, default_folder=default_folder)
150
+ if args.path.is_file():
151
+ destination = process_text_file(
152
+ args.path,
153
+ lines_per_block=args.lines_per_block,
154
+ gap_lines=args.gap_lines,
155
+ )
156
+ print(f"Đã xử lý {args.path.name}, lưu tại {destination.name}")
157
+ else:
158
+ process_folder(
159
+ args.path,
160
+ lines_per_block=args.lines_per_block,
161
+ gap_lines=args.gap_lines,
162
+ )
163
+ return 0
164
+
165
+
166
+ if __name__ == "__main__":
167
+ raise SystemExit(main())
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "student-email-tools"
7
+ version = "0.2.0"
8
+ description = "Python tools for generating and formatting student email lists."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "openpyxl>=3.1",
13
+ "pandas>=2.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "mypy>=1.13",
19
+ "pytest>=8.0",
20
+ "ruff>=0.9",
21
+ ]
22
+ xls = ["xlrd>=2.0"]
23
+
24
+ [project.scripts]
25
+ generate-emails = "email_tools.email_generator:main"
26
+ format-email-blocks = "email_tools.text_blocks:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["email_tools*"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ pythonpath = ["."]
34
+ addopts = "-ra"
35
+
36
+ [tool.ruff]
37
+ line-length = 88
38
+ target-version = "py310"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "UP"]
42
+
43
+ [tool.mypy]
44
+ python_version = "3.10"
45
+ check_untyped_defs = true
46
+ ignore_missing_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: student-email-tools
3
+ Version: 0.2.0
4
+ Summary: Python tools for generating and formatting student email lists.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: openpyxl>=3.1
9
+ Requires-Dist: pandas>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.13; extra == "dev"
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.9; extra == "dev"
14
+ Provides-Extra: xls
15
+ Requires-Dist: xlrd>=2.0; extra == "xls"
16
+ Dynamic: license-file
17
+
18
+ <div align="center">
19
+
20
+ # student-email-tools
21
+
22
+ Python tools for generating and formatting student email lists from local files.
23
+
24
+ [![CI](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml)
25
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
+
27
+ </div>
28
+
29
+ ## Table of Contents
30
+
31
+ - [1. Overview](#1-overview)
32
+ - [2. Requirements](#2-requirements)
33
+ - [3. Installation](#3-installation)
34
+ - [4. Usage](#4-usage)
35
+ - [4.1 Generate emails from Excel](#41-generate-emails-from-excel)
36
+ - [4.2 Format TXT files](#42-format-txt-files)
37
+ - [5. Development](#5-development)
38
+ - [6. Contributing and support](#6-contributing-and-support)
39
+ - [7. Releases](#7-releases)
40
+ - [8. License](#8-license)
41
+
42
+ ## 1. Overview
43
+
44
+ This project provides two commands:
45
+
46
+ - Generate email addresses from the `First name` and `Last name` columns in an
47
+ Excel file.
48
+ - Split a TXT file into spaced line blocks that are easy to copy and send.
49
+
50
+ Excel files, email lists, virtual environments, and caches are local data; they
51
+ do not belong in this repository.
52
+
53
+ ## 2. Requirements
54
+
55
+ - Python 3.10 or newer.
56
+ - `pandas` and `openpyxl` for `.xlsx` files.
57
+ - Install the `xls` extra when `.xls` support is needed.
58
+
59
+ ## 3. Installation
60
+
61
+ ```powershell
62
+ python -m venv .venv
63
+ ./.venv/Scripts/python.exe -m pip install -e ".[dev]"
64
+ ```
65
+
66
+ For `.xls` support:
67
+
68
+ ```powershell
69
+ ./.venv/Scripts/python.exe -m pip install -e ".[xls]"
70
+ ```
71
+
72
+ ## 4. Usage
73
+
74
+ ### 4.1 Generate emails from Excel
75
+
76
+ Place Excel files in a dedicated directory and run:
77
+
78
+ ```powershell
79
+ python emails.py "D:/data/course"
80
+ ```
81
+
82
+ Or use the entry point after installing the package:
83
+
84
+ ```powershell
85
+ generate-emails "D:/data/course"
86
+ ```
87
+
88
+ Each Excel file produces a TXT file with the same basename. The parser handles
89
+ mixed code/name formats, Vietnamese diacritics, and invalid local-part
90
+ characters.
91
+
92
+ ### 4.2 Format TXT files
93
+
94
+ ```powershell
95
+ python 10lines.py "D:/data/emails.txt"
96
+ ```
97
+
98
+ The command creates an `_output.txt` file, with 500 lines per block and 10 blank
99
+ lines between blocks by default. Customize the layout with:
100
+
101
+ ```powershell
102
+ format-email-blocks "D:/data/emails.txt" --lines-per-block 100 --gap-lines 2
103
+ ```
104
+
105
+ ## 5. Development
106
+
107
+ ```powershell
108
+ python -m pytest -q
109
+ python -m ruff check email_tools emails.py 10lines.py scripts/ci_runtime.py tests
110
+ python -m ruff format --check email_tools emails.py 10lines.py scripts/ci_runtime.py tests
111
+ python -m mypy --ignore-missing-imports email_tools emails.py 10lines.py scripts/ci_runtime.py
112
+ ```
113
+
114
+ ## 6. Contributing and support
115
+
116
+ Read [CONTRIBUTING.md](CONTRIBUTING.md), [SUPPORT.md](SUPPORT.md), and
117
+ [SECURITY.md](SECURITY.md). Do not commit real student data or email lists.
118
+
119
+ ## 7. Releases
120
+
121
+ Release Please creates the release pull request and GitHub Release. The release
122
+ workflow also builds source and wheel distributions and publishes them to PyPI
123
+ using Trusted Publishing.
124
+
125
+ Before the first package release, configure a PyPI Trusted Publisher with:
126
+
127
+ - Owner: `MinhThang1009`
128
+ - Repository: `student-email-tools`
129
+ - Workflow: `.github/workflows/release.yml`
130
+ - GitHub environment: `pypi`
131
+
132
+ See the [PyPI Trusted Publishers guide](https://docs.pypi.org/trusted-publishers/).
133
+
134
+ ## 8. License
135
+
136
+ This project is released under the [MIT License](LICENSE).
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ email_tools/__init__.py
5
+ email_tools/email_generator.py
6
+ email_tools/text_blocks.py
7
+ student_email_tools.egg-info/PKG-INFO
8
+ student_email_tools.egg-info/SOURCES.txt
9
+ student_email_tools.egg-info/dependency_links.txt
10
+ student_email_tools.egg-info/entry_points.txt
11
+ student_email_tools.egg-info/requires.txt
12
+ student_email_tools.egg-info/top_level.txt
13
+ tests/test_ci_runtime.py
14
+ tests/test_email_generator.py
15
+ tests/test_text_blocks.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ format-email-blocks = email_tools.text_blocks:main
3
+ generate-emails = email_tools.email_generator:main
@@ -0,0 +1,10 @@
1
+ openpyxl>=3.1
2
+ pandas>=2.0
3
+
4
+ [dev]
5
+ mypy>=1.13
6
+ pytest>=8.0
7
+ ruff>=0.9
8
+
9
+ [xls]
10
+ xlrd>=2.0
@@ -0,0 +1,23 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from scripts.ci_runtime import build_outputs, load_policy
6
+
7
+
8
+ def test_build_outputs_creates_matrix_and_canary() -> None:
9
+ matrix, canary = build_outputs({"supported": ["3.10", "3.11"], "canary": "3.x"})
10
+
11
+ assert json.loads(matrix) == {"python-version": ["3.10", "3.11"]}
12
+ assert canary == "3.x"
13
+
14
+
15
+ def test_repository_policy_includes_python_314() -> None:
16
+ matrix, _ = build_outputs(load_policy())
17
+
18
+ assert "3.14" in json.loads(matrix)["python-version"]
19
+
20
+
21
+ def test_build_outputs_rejects_duplicate_supported_versions() -> None:
22
+ with pytest.raises(ValueError, match="duplicate"):
23
+ build_outputs({"supported": ["3.12", "3.12"], "canary": "3.x"})
@@ -0,0 +1,77 @@
1
+ import pandas as pd
2
+
3
+ from email_tools.email_generator import (
4
+ email_sort_key,
5
+ extract_component,
6
+ extract_last_component_from_row,
7
+ generate_emails,
8
+ normalize_local_part,
9
+ )
10
+
11
+
12
+ def test_extract_component_supports_values_with_or_without_hyphens() -> None:
13
+ assert extract_component(" Nguyễn Văn - 71K01 ") == "Nguyễn Văn"
14
+ assert extract_component("Nguyễn Văn") == "Nguyễn Văn"
15
+ assert extract_component("- 71K01", leading_hyphen_uses_last_word=True) is None
16
+ assert (
17
+ extract_component("- Nguyễn Văn A", leading_hyphen_uses_last_word=True) == "A"
18
+ )
19
+ assert extract_component(None) is None
20
+
21
+
22
+ def test_leading_class_separator_uses_given_name_from_first_name() -> None:
23
+ assert (
24
+ extract_last_component_from_row(
25
+ "24772020101CT - Nguyễn Lê An Như", "- 71K31DUOC01"
26
+ )
27
+ == "Như"
28
+ )
29
+ assert (
30
+ extract_last_component_from_row("2500115423 - Hồ Nguyễn Trung Khoa", "-")
31
+ == "Khoa"
32
+ )
33
+
34
+
35
+ def test_normalize_local_part_handles_vietnamese_characters() -> None:
36
+ assert normalize_local_part("Đặng Ánh") == "danganh"
37
+ assert normalize_local_part(".,-") is None
38
+
39
+
40
+ def test_email_sort_key_requires_a_number_after_a_dot() -> None:
41
+ numbered = "an.207tt50948@vanlanguni.vn"
42
+ no_dot = "24042108031986@vanlanguni.vn"
43
+
44
+ assert email_sort_key(numbered) < email_sort_key(no_dot)
45
+ assert email_sort_key("an.207@other.example") == (0, 207, "an.207@other.example")
46
+
47
+
48
+ def test_generate_emails_handles_mixed_source_formats() -> None:
49
+ dataframe = pd.DataFrame(
50
+ {
51
+ "First name": [
52
+ "2500115424 - Nguyễn Văn A",
53
+ "24042108031986 - Lê Thị Hậu",
54
+ "24772020101CT - Nguyễn Lê An Như",
55
+ "2500115425 - Nguyễn Văn C",
56
+ "2500115426",
57
+ "2673201040001 - Soles",
58
+ ],
59
+ "Last name": [
60
+ "An - 71K01",
61
+ "An - 71K01",
62
+ "- 71K31DUOC01",
63
+ "Ý - 71K01",
64
+ "Bình",
65
+ "Adam",
66
+ ],
67
+ }
68
+ )
69
+
70
+ assert generate_emails(dataframe) == [
71
+ "an.2500115424@vanlanguni.vn",
72
+ "y.2500115425@vanlanguni.vn",
73
+ "binh.2500115426@vanlanguni.vn",
74
+ "nhu.24772020101ct@vanlanguni.vn",
75
+ "24042108031986@vanlanguni.vn",
76
+ "2673201040001@vanlanguni.vn",
77
+ ]
@@ -0,0 +1,31 @@
1
+ from email_tools.text_blocks import (
2
+ find_text_files,
3
+ format_lines,
4
+ process_folder,
5
+ process_text_file,
6
+ )
7
+
8
+
9
+ def test_format_lines_adds_gaps_only_between_blocks() -> None:
10
+ assert format_lines(["a", "b", "c"], lines_per_block=2, gap_lines=2) == [
11
+ "a",
12
+ "b",
13
+ "",
14
+ "",
15
+ "c",
16
+ ]
17
+
18
+
19
+ def test_process_folder_does_not_reprocess_generated_outputs(tmp_path) -> None:
20
+ source = tmp_path / "source.txt"
21
+ source.write_text("a\nb\nc\n", encoding="utf-8")
22
+
23
+ outputs = process_folder(tmp_path, lines_per_block=2, gap_lines=1)
24
+ output = tmp_path / "source_output.txt"
25
+
26
+ assert outputs == [output.resolve()]
27
+ assert output.read_text(encoding="utf-8") == "a\nb\n\nc\n"
28
+ assert find_text_files(tmp_path) == [source]
29
+
30
+ process_text_file(source, lines_per_block=2, gap_lines=1)
31
+ assert not (tmp_path / "source_output_output.txt").exists()