Skip to content

Commit 0c4e8a6

Browse files
authored
Fix hooks, CLI, and parsing (#21)
* Fix hooks, CLI, and parsing * linting fix
1 parent 3161573 commit 0c4e8a6

12 files changed

Lines changed: 713 additions & 76 deletions

File tree

.github/workflows/run-tests.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,16 @@ jobs:
2525
# run pre-commit ci lite for automated fixes
2626
- uses: pre-commit-ci/lite-action@v1.1.0
2727
if: ${{ !cancelled() }}
28-
# Test that the hooks from `pre-commit-hooks.yaml`
29-
# are working as expected.
28+
# Test that the hooks from `pre-commit-hooks.yaml` are working as
29+
# expected by running the check hook against known-compliant fixtures.
30+
# The non-compliant fixtures under tests/data are intentional violations
31+
# and are exercised through pre-commit and prek by the pytest suite.
3032
- name: run local hook
3133
run: |
32-
pre-commit try-repo . --all
34+
pre-commit try-repo . check --files \
35+
tests/data/2_true_neg.md \
36+
tests/data/4_true_neg.rst \
37+
tests/data/5_policy_compliant.md
3338
run_tests:
3439
strategy:
3540
matrix:

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
args: ["--keep", "mdformat", "--keep", "pre-commit-update"]
2323

2424
- repo: https://github.com/tox-dev/pyproject-fmt
25-
rev: "v2.23.0"
25+
rev: "v2.25.0"
2626
hooks:
2727
- id: pyproject-fmt
2828
- repo: https://github.com/codespell-project/codespell

.pre-commit-hooks.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
description: Check if each line in the given file contains only one sentence.
66
entry: onesentence check
77
language: python
8-
types: [text, markdown, rst]
8+
# `types` is an AND-intersection, so `text` matches Markdown, reST, and plain
9+
# text alike; the `files` regex narrows that to the extensions we support.
10+
types: [text]
911
files: \.(md|rst|txt)$
1012
# fix
1113
- id: fix
1214
name: One Sentence Per Line Fix
1315
description: Fix files to ensure each line contains only one sentence.
1416
entry: onesentence fix
1517
language: python
16-
types: [text, markdown, rst]
18+
types: [text]
1719
files: \.(md|rst|txt)$

README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,21 @@ The `onesentence` tool provides a command-line interface for checking and fixing
3333
#### Commands
3434

3535
```bash
36-
onesentence check <file_path>
36+
onesentence check <file_path> [<file_path> ...]
3737
```
3838

39-
This command checks if the specified file adheres to the "one sentence per line" rule. It will return a non-zero exit code if any violations are found.
39+
This command checks whether each given file adheres to the "one sentence per line" rule.
40+
One or more files may be passed (for example, the filenames pre-commit hands to a hook).
41+
It returns a non-zero exit code if any file has a violation.
4042

4143
```bash
42-
onesentence fix <file_path> [<dest_path>]
44+
onesentence fix <file_path> [<file_path> ...] [--output <path>]
4345
```
4446

45-
This command corrects the specified file by splitting lines with multiple sentences into separate lines. If a dest_path is provided, the corrected file will be written to that path; otherwise, the original file will be overwritten.
47+
This command corrects each given file by splitting lines with multiple sentences onto separate lines.
48+
By default every file is corrected in place, so processing many files never lets one file overwrite another.
49+
Pass `--output <path>` to write a single corrected file to a separate destination; this is only valid with exactly one input file.
50+
It returns a non-zero exit code if any file required changes.
4651

4752
## Pre-commit hook
4853

@@ -51,10 +56,28 @@ Install this pre-commit hook into your project with a block like the following:
5156
```yaml
5257
repos:
5358
- repo: https://github.com/CU-DBMI/onesentence
54-
rev: v0.0.1
59+
rev: v0.1.1
5560
hooks:
5661
# run checks
5762
- id: check
5863
# run checks and fixes where possible
5964
- id: fix
6065
```
66+
67+
### Using onesentence with a Markdown formatter
68+
69+
If you also run a Markdown formatter such as
70+
[`mdformat`](https://github.com/executablebooks/mdformat), configure it to
71+
preserve existing line breaks so it does not undo the one-sentence-per-line
72+
splitting.
73+
For `mdformat` this means using `--wrap=keep` (the default), and notably **not**
74+
`--wrap=no` or a fixed wrap width, either of which would rejoin sentences onto a
75+
single line.
76+
77+
```yaml
78+
- repo: https://github.com/executablebooks/mdformat
79+
rev: 0.7.22
80+
hooks:
81+
- id: mdformat
82+
args: ["--wrap=keep"]
83+
```

pyproject.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ scripts.onesentence = "onesentence.cli:trigger"
2626
[dependency-groups]
2727
dev = [
2828
"coverage>=7.6.12",
29+
"mdformat>=0.7.22",
30+
"pre-commit>=4",
31+
"prek>=0.4.5",
2932
"pytest>=8.3.5",
3033
]
3134

3235
[tool.setuptools_scm]
3336
root = "."
34-
35-
[tool.uv]
36-
dev-dependencies = [
37-
"pre-commit>=4.0.0",
38-
]

src/onesentence/analyze.py

Lines changed: 118 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,91 @@
22
Module for checking for one sentence per line and related.
33
"""
44

5-
import pysbd
65
import re
7-
from typing import Optional
6+
from typing import List, Optional
7+
8+
import pysbd
9+
10+
# A single segmenter is reused across calls; constructing one per line is
11+
# needless overhead and the segmenter is stateless between ``segment`` calls.
12+
_SEGMENTER = pysbd.Segmenter(language="en", clean=False)
13+
14+
# Non-prose Markdown / reStructuredText structures that are exempt from the
15+
# "one sentence per line" rule. These are matched against the stripped line.
16+
_HEADING_RE = re.compile(r"^#{1,6}(\s|$)") # ATX heading, incl. "### 1. Foo"
17+
_RULE_OR_UNDERLINE_RE = re.compile(
18+
r"^[=\-~`#*_]+$"
19+
) # setext underline, thematic break, or emphasis-only line
20+
_RST_DIRECTIVE_RE = re.compile(r"^\.\.\s+\w+::") # ".. directive::"
21+
_LINK_DEFINITION_RE = re.compile(
22+
r"^\[[^\]]+\]:\s+\S"
23+
) # link reference definition, e.g. "[id]: https://example.com"
24+
_TABLE_ROW_RE = re.compile(r"^\|") # "| cell | cell |"
25+
_TABLE_SEPARATOR_RE = re.compile(
26+
r"^\|?[\s:|-]*\|[\s:|-]*$"
27+
) # "|---|:--:|" style separators
28+
29+
# Prose constructs whose internal punctuation must not be read as sentence
30+
# boundaries; each is replaced with a single opaque token before segmentation.
31+
_INLINE_CODE_RE = re.compile(r"``[^`]*``|`[^`]*`") # `code` / ``code``
32+
_INLINE_LINK_RE = re.compile(r"!?\[[^\]]*\]\([^)]*\)") # [text](url) / ![alt](url)
33+
_REFERENCE_LINK_RE = re.compile(r"\[[^\]]*\]\[[^\]]*\]") # [text][ref]
34+
_AUTOLINK_RE = re.compile(r"<[^>\s]+>") # <https://example.com>
35+
# Bare URLs, stopping before any trailing sentence punctuation so a period that
36+
# actually ends the sentence is preserved.
37+
_BARE_URL_RE = re.compile(
38+
r"\b(?:https?|ftp)://[^\s]+?(?=[.,!?;:'\")\]]*(?:\s|$))"
39+
r"|\bwww\.[^\s]+?(?=[.,!?;:'\")\]]*(?:\s|$))"
40+
)
41+
# Remaining markup (emphasis, heading markers, pipes, ...) that is not part of
42+
# sentence structure.
43+
_NON_SENTENCE_CHARS_RE = re.compile(r"[^a-zA-Z0-9\s.,!?\'\"()\-]")
44+
45+
46+
def _is_structural_line(stripped: str) -> bool:
47+
"""
48+
Return True for non-prose Markdown/reST lines exempt from the rule.
49+
50+
Args:
51+
stripped (str): The line with surrounding whitespace removed.
52+
53+
Returns:
54+
bool: True if the line is a heading, horizontal rule / underline,
55+
reST directive, link reference definition, or table row/separator.
56+
"""
57+
return bool(
58+
_HEADING_RE.match(stripped)
59+
or _RULE_OR_UNDERLINE_RE.match(stripped)
60+
or _RST_DIRECTIVE_RE.match(stripped)
61+
or _LINK_DEFINITION_RE.match(stripped)
62+
or _TABLE_ROW_RE.match(stripped)
63+
or _TABLE_SEPARATOR_RE.match(stripped)
64+
)
65+
66+
67+
def _mask_inline_constructs(text: str) -> str:
68+
"""
69+
Replace inline code, links, and URLs with opaque tokens.
70+
71+
This keeps their internal punctuation (dots in URLs, abbreviations inside
72+
code, ...) from being treated as sentence boundaries while leaving the
73+
surrounding prose intact for segmentation.
74+
75+
Args:
76+
text (str): The stripped line to mask.
77+
78+
Returns:
79+
str: The line with inline constructs and stray markup removed.
80+
"""
81+
# Order matters: code first, then links (which may wrap URLs), then any
82+
# remaining autolinks / bare URLs.
83+
text = _INLINE_CODE_RE.sub("INLINECODE", text)
84+
text = _INLINE_LINK_RE.sub("LINK", text)
85+
text = _REFERENCE_LINK_RE.sub("LINK", text)
86+
text = _AUTOLINK_RE.sub("URL", text)
87+
text = _BARE_URL_RE.sub("URL", text)
88+
return _NON_SENTENCE_CHARS_RE.sub("", text)
89+
890

991
def is_single_sentence(line: str, ignore_block: bool) -> bool:
1092
"""
@@ -29,34 +111,28 @@ def is_single_sentence(line: str, ignore_block: bool) -> bool:
29111
if ignore_block:
30112
return True
31113

32-
# Additional filtering for common reST and Markdown formatting
33-
if re.match(r'^[=\-~`#\*]+$', line.strip()):
34-
return True
35-
if re.match(r'^\.\.\s+\w+::', line.strip()):
114+
stripped = line.strip()
115+
116+
# Ignore non-prose structures (headings, rules, link definitions, tables,
117+
# reST directives). Numbered headings such as "### 1. Foo" are covered here.
118+
if _is_structural_line(stripped):
36119
return True
37120

38121
# Allow multiple sentences in list items, their continuations, and blockquotes
39-
if re.match(r'^\s*[-*+]\s+', line): # Unordered list item
122+
if re.match(r"^\s*[-*+]\s+", line): # Unordered list item
40123
return True
41-
if re.match(r'^\s*\d+\.\s+', line): # Ordered list item
124+
if re.match(r"^\s*\d+\.\s+", line): # Ordered list item
42125
return True
43-
if re.match(r'^\s+\S', line): # Indented continuation of a list item
126+
if re.match(r"^\s+\S", line): # Indented continuation of a list item
44127
return True
45-
if re.match(r'^>\s*', line): # Blockquote
128+
if re.match(r"^>\s*", line): # Blockquote
46129
return True
47130

48-
line = line.strip()
49-
50-
# Mask inline code spans so their content doesn't trigger false sentence breaks
51-
# Double backticks (reST) must be matched before single backticks (Markdown)
52-
line = re.sub(r'``[^`]*``|`[^`]*`', 'INLINECODE', line)
131+
# Mask inline code, links, and URLs so their punctuation does not trigger
132+
# false sentence breaks, then count the remaining sentences.
133+
masked = _mask_inline_constructs(stripped)
134+
return len(_SEGMENTER.segment(masked)) == 1
53135

54-
# Remove special characters that do not pertain to sentence structure
55-
line = re.sub(r'[^a-zA-Z0-9\s.,!?\'"()\-]', '', line)
56-
57-
segmenter = pysbd.Segmenter(language="en", clean=False)
58-
sentences = segmenter.segment(line)
59-
return len(sentences) == 1
60136

61137
def check_file_for_one_sentence_per_line(file_path: str) -> bool:
62138
"""
@@ -71,9 +147,9 @@ def check_file_for_one_sentence_per_line(file_path: str) -> bool:
71147
all_single_sentences = True
72148
ignore_block = False
73149
in_code_block = False
74-
with open(file_path, 'r') as file:
150+
with open(file_path, "r") as file:
75151
for line_number, line in enumerate(file, start=1):
76-
if line.strip().startswith('```'):
152+
if line.strip().startswith("```"):
77153
in_code_block = not in_code_block
78154
continue
79155
if "noqa: onesentence-start" in line:
@@ -82,12 +158,15 @@ def check_file_for_one_sentence_per_line(file_path: str) -> bool:
82158
if "noqa: onesentence-end" in line:
83159
ignore_block = False
84160
continue
85-
if not is_single_sentence(line.rstrip('\n'), ignore_block or in_code_block):
161+
if not is_single_sentence(line.rstrip("\n"), ignore_block or in_code_block):
86162
print(f"Failed: line {line_number}: {line.strip()}")
87163
all_single_sentences = False
88164
return all_single_sentences
89165

90-
def correct_file_for_one_sentence_per_line(file_path: str, dest_path: Optional[str] = None) -> bool:
166+
167+
def correct_file_for_one_sentence_per_line(
168+
file_path: str, dest_path: Optional[str] = None
169+
) -> bool:
91170
"""
92171
Check if each line in the given file contains only one sentence.
93172
If not, correct the file by replacing the contents with correctly segmented sentences.
@@ -105,16 +184,16 @@ def correct_file_for_one_sentence_per_line(file_path: str, dest_path: Optional[s
105184
all_single_sentences = True
106185
ignore_block = False
107186
in_code_block = False
108-
corrected_lines = []
109-
110-
segmenter = pysbd.Segmenter(language="en", clean=False)
187+
corrected_lines: List[str] = []
111188

112-
with open(file_path, 'r') as file:
189+
with open(file_path, "r") as file:
113190
for line_number, line in enumerate(file, start=1):
114-
original_indent = re.match(r'^\s*', line).group() # Capture the original indentation
191+
original_indent = re.match(
192+
r"^\s*", line
193+
).group() # Capture the original indentation
115194
stripped_line = line.strip()
116195

117-
if stripped_line.startswith('```'):
196+
if stripped_line.startswith("```"):
118197
in_code_block = not in_code_block
119198
corrected_lines.append(line.rstrip())
120199
continue
@@ -126,17 +205,19 @@ def correct_file_for_one_sentence_per_line(file_path: str, dest_path: Optional[s
126205
ignore_block = False
127206
corrected_lines.append(line.rstrip())
128207
continue
129-
if not is_single_sentence(line.rstrip('\n'), ignore_block or in_code_block):
208+
if not is_single_sentence(line.rstrip("\n"), ignore_block or in_code_block):
130209
print(f"Failed: line {line_number}: {stripped_line}")
131210
all_single_sentences = False
132211
if not ignore_block:
133-
sentences = segmenter.segment(stripped_line)
212+
sentences = _SEGMENTER.segment(stripped_line)
134213
# Detect and move lines with only Markdown characters to the end of the second-to-last line
135-
if sentences and re.match(r'^[=\-~`#\*]+$', sentences[-1]):
214+
if sentences and re.match(r"^[=\-~`#\*]+$", sentences[-1]):
136215
markdown_line = sentences.pop()
137216
if sentences:
138217
sentences[-1] += markdown_line
139-
corrected_lines.extend([original_indent + sentence.strip() for sentence in sentences])
218+
corrected_lines.extend(
219+
[original_indent + sentence.strip() for sentence in sentences]
220+
)
140221
else:
141222
corrected_lines.append(line.rstrip())
142223
else:
@@ -147,8 +228,8 @@ def correct_file_for_one_sentence_per_line(file_path: str, dest_path: Optional[s
147228
dest_path = file_path
148229

149230
# Write the corrected content back to the file
150-
with open(dest_path, 'w') as file:
231+
with open(dest_path, "w") as file:
151232
for corrected_line in corrected_lines:
152-
file.write(corrected_line + '\n')
233+
file.write(corrected_line + "\n")
153234

154235
return all_single_sentences

0 commit comments

Comments
 (0)