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.
- student_email_tools-0.2.0/LICENSE +21 -0
- student_email_tools-0.2.0/PKG-INFO +136 -0
- student_email_tools-0.2.0/README.md +119 -0
- student_email_tools-0.2.0/email_tools/__init__.py +5 -0
- student_email_tools-0.2.0/email_tools/email_generator.py +291 -0
- student_email_tools-0.2.0/email_tools/text_blocks.py +167 -0
- student_email_tools-0.2.0/pyproject.toml +46 -0
- student_email_tools-0.2.0/setup.cfg +4 -0
- student_email_tools-0.2.0/student_email_tools.egg-info/PKG-INFO +136 -0
- student_email_tools-0.2.0/student_email_tools.egg-info/SOURCES.txt +15 -0
- student_email_tools-0.2.0/student_email_tools.egg-info/dependency_links.txt +1 -0
- student_email_tools-0.2.0/student_email_tools.egg-info/entry_points.txt +3 -0
- student_email_tools-0.2.0/student_email_tools.egg-info/requires.txt +10 -0
- student_email_tools-0.2.0/student_email_tools.egg-info/top_level.txt +1 -0
- student_email_tools-0.2.0/tests/test_ci_runtime.py +23 -0
- student_email_tools-0.2.0/tests/test_email_generator.py +77 -0
- student_email_tools-0.2.0/tests/test_text_blocks.py +31 -0
|
@@ -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
|
+
[](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml)
|
|
25
|
+
[](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
|
+
[](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml)
|
|
8
|
+
[](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,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,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
|
+
[](https://github.com/MinhThang1009/student-email-tools/actions/workflows/ci.yml)
|
|
25
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
email_tools
|
|
@@ -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()
|