hashident 0.1.0__py3-none-any.whl

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.
hashident/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """hashident: identify likely hash algorithms from a hash string."""
2
+
3
+ __version__ = "0.1.0"
hashident/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """Command-line interface for hashident."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import functools
7
+ import itertools
8
+ import os
9
+ import sys
10
+ import textwrap
11
+ from collections.abc import Iterable, Iterator, Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any, NoReturn
14
+
15
+ from hashident import __version__, json_output
16
+ from hashident.engine import identify
17
+ from hashident.inputs import InputError, InputItem, gather
18
+ from hashident.models import Context, Result
19
+ from hashident.output import WIDTH, CompactTable, Positions, format_result, report_rule
20
+ from hashident.style import Styler
21
+ from hashident.terminal import COLOR_MODES, use_color
22
+ from hashident.tips import ContextTip, context_tips
23
+
24
+ _EPILOG = """\
25
+ Input: hashes given as arguments are reported first, in order, then the
26
+ lines of -f PATH. With neither, hashes are read from standard input when it
27
+ is piped or redirected. In files and piped input, blank lines and lines
28
+ starting with # are skipped. UTF-8 and UTF-16 (with a BOM) are understood.
29
+
30
+ Output: one hash gets a full report with reasons and crack commands.
31
+ Several hashes get a compact table, one row each (argument index or line
32
+ number, best format, confidence, Hashcat mode), then a count per format.
33
+ With -v, every hash gets a full report instead, under a rule naming it,
34
+ and low-confidence candidates get details as well.
35
+
36
+ JSON: --json prints one JSON object per hash, each on its own line (JSON
37
+ Lines), never colored. --json ignores -v: JSON always includes everything.
38
+ Keys, in schema_version 1: schema_version, input, source, position,
39
+ context, matched, candidates, tips. Each candidate: rank, format, score,
40
+ confidence, hashcat_mode, john_format, strength, strength_note, reasons,
41
+ commands, notes. Each tip: context, top_format. Absent values are null.
42
+
43
+ Quoting: hashes that contain $ (bcrypt, md5crypt, sha512crypt) or start
44
+ with * (MySQL) must be wrapped in single quotes, in PowerShell and bash
45
+ alike. Otherwise the shell expands them before hashident sees them:
46
+ hashident '$2a$05$LhayLxezLhK1LhWvKxCyLOj0j1u.Kj0jZ0pEmm134uzrQlFvQJLF6'
47
+
48
+ Color: --color auto (the default) colors output only when it goes to a
49
+ terminal and the NO_COLOR environment variable is unset or empty. Order of
50
+ precedence: --color/--no-color, then NO_COLOR, then terminal detection.
51
+ Piped or redirected output is plain unless you pass --color always.
52
+
53
+ Exit status:
54
+ 0 every hash matched a known format
55
+ 1 none did
56
+ 3 some did and some did not
57
+ 2 invalid arguments, or input that cannot be read or has no hashes"""
58
+
59
+ EXIT_ALL_MATCHED = 0
60
+ EXIT_NONE_MATCHED = 1
61
+ EXIT_ERROR = 2
62
+ EXIT_SOME_MATCHED = 3
63
+
64
+ # Help and errors are always plain: argparse decides on its own color (3.14+)
65
+ # before --color is parsed, so it could not honour our flags anyway.
66
+ _PARSER_OPTIONS: dict[str, Any] = {"color": False} if sys.version_info >= (3, 14) else {}
67
+
68
+
69
+ class _Parser(argparse.ArgumentParser):
70
+ """ArgumentParser whose error messages are wrapped to 80 columns."""
71
+
72
+ def error(self, message: str) -> NoReturn:
73
+ self.print_usage(sys.stderr)
74
+ text = textwrap.fill(f"{self.prog}: error: {message}", width=WIDTH, subsequent_indent=" ")
75
+ self.exit(2, text + "\n")
76
+
77
+
78
+ def build_parser() -> argparse.ArgumentParser:
79
+ parser = _Parser(
80
+ prog="hashident",
81
+ description="Identify likely hash algorithms from a hash string.",
82
+ epilog=_EPILOG,
83
+ formatter_class=functools.partial(argparse.RawDescriptionHelpFormatter, width=WIDTH),
84
+ **_PARSER_OPTIONS,
85
+ )
86
+ parser.add_argument(
87
+ "--version",
88
+ action="version",
89
+ version=f"%(prog)s {__version__}",
90
+ )
91
+ parser.add_argument(
92
+ "--from",
93
+ dest="context",
94
+ type=str.lower,
95
+ choices=[c.value for c in Context],
96
+ help="where the hash came from; ranks formats common there higher",
97
+ )
98
+ color = parser.add_mutually_exclusive_group()
99
+ color.add_argument(
100
+ "--color",
101
+ type=str.lower,
102
+ choices=COLOR_MODES,
103
+ help="when to use color (default: auto)",
104
+ )
105
+ color.add_argument(
106
+ "--no-color",
107
+ dest="color",
108
+ action="store_const",
109
+ const="never",
110
+ help="same as --color never",
111
+ )
112
+ parser.set_defaults(color="auto")
113
+ parser.add_argument(
114
+ "-f",
115
+ "--file",
116
+ metavar="PATH",
117
+ help="read hashes from PATH, one per line; - means standard input",
118
+ )
119
+ parser.add_argument(
120
+ "-v",
121
+ "--verbose",
122
+ action="store_true",
123
+ help="full report for every hash, with details of low-confidence candidates too",
124
+ )
125
+ parser.add_argument(
126
+ "--json",
127
+ action="store_true",
128
+ help="print JSON, one object per hash per line (see JSON below)",
129
+ )
130
+ parser.add_argument("hashes", nargs="*", metavar="hash", help="a hash to identify")
131
+ return parser
132
+
133
+
134
+ def main(argv: Sequence[str] | None = None) -> int:
135
+ parser = build_parser()
136
+ args = parser.parse_args(argv)
137
+ try:
138
+ items = gather(args.hashes, args.file)
139
+ if items is None:
140
+ parser.print_help()
141
+ return EXIT_ALL_MATCHED
142
+
143
+ first, second = next(items, None), next(items, None)
144
+ if first is None:
145
+ return _fail(parser, "no hashes found in the input; see hashident --help")
146
+ several = second is not None
147
+ values = itertools.chain((first, second) if several else (first,), items)
148
+
149
+ tally = _Tally()
150
+ analysed = _analyse(values, args.context, tally)
151
+ if args.json:
152
+ # Checked before use_color, which may change the console's mode.
153
+ _print_json(analysed)
154
+ else:
155
+ style = Styler(enabled=use_color(args.color, sys.stdout, os.environ))
156
+ if several and not args.verbose:
157
+ _print_compact(analysed, CompactTable(_positions(args), style))
158
+ else:
159
+ _print_reports(analysed, style, several=several, show_low=args.verbose)
160
+ return tally.exit_status()
161
+ except InputError as exc:
162
+ return _fail(parser, str(exc))
163
+
164
+
165
+ _Analysed = Iterator[tuple[InputItem, Result, tuple[ContextTip, ...]]]
166
+
167
+
168
+ @dataclass
169
+ class _Tally:
170
+ """Counts of values with and without a match, for the exit status."""
171
+
172
+ matched: int = 0
173
+ unmatched: int = 0
174
+
175
+ def exit_status(self) -> int:
176
+ if not self.unmatched:
177
+ return EXIT_ALL_MATCHED
178
+ return EXIT_SOME_MATCHED if self.matched else EXIT_NONE_MATCHED
179
+
180
+
181
+ def _analyse(items: Iterable[InputItem], context: str | None, tally: _Tally) -> _Analysed:
182
+ """Identify each value as it arrives, counting matches into ``tally``."""
183
+ for item in items:
184
+ result = identify(item.value, context=context)
185
+ if result.candidates:
186
+ tally.matched += 1
187
+ else:
188
+ tally.unmatched += 1
189
+ yield item, result, context_tips(result)
190
+
191
+
192
+ def _print_json(analysed: _Analysed) -> None:
193
+ for item, result, tips in analysed:
194
+ print(json_output.dumps(json_output.to_record(item, result, tips)))
195
+
196
+
197
+ def _print_reports(analysed: _Analysed, style: Styler, *, several: bool, show_low: bool) -> None:
198
+ """Full reports; with several values, each under a rule naming it."""
199
+ for index, (item, result, tips) in enumerate(analysed):
200
+ if several:
201
+ if index:
202
+ print()
203
+ print(report_rule(item))
204
+ print(format_result(result, tips=tips, style=style, show_low=show_low))
205
+
206
+
207
+ def _print_compact(analysed: _Analysed, table: CompactTable) -> None:
208
+ print("\n".join(table.header()))
209
+ for item, result, tips in analysed:
210
+ print(table.row(item, result, tips))
211
+ print("\n".join(table.footer()))
212
+
213
+
214
+ def _positions(args: argparse.Namespace) -> Positions:
215
+ """What the compact table's first column counts, given the inputs used."""
216
+ if not args.hashes:
217
+ return Positions.LINE # -f, or piped stdin
218
+ return Positions.ARGUMENT if args.file is None else Positions.MIXED
219
+
220
+
221
+ def _fail(parser: argparse.ArgumentParser, message: str) -> int:
222
+ """Print a one-line error (never wrapped: it may hold a path)."""
223
+ print(f"{parser.prog}: error: {message}", file=sys.stderr)
224
+ return EXIT_ERROR
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
hashident/commands.py ADDED
@@ -0,0 +1,39 @@
1
+ """Ready-to-run cracking commands for a signature.
2
+
3
+ Kept separate from text formatting so other output formats (e.g. JSON)
4
+ can reuse the same commands and notes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from hashident.models import Signature
10
+ from hashident.signatures import MYSQL41
11
+
12
+ HASH_FILE = "hash.txt"
13
+ WORDLIST = "wordlist.txt"
14
+
15
+
16
+ def crack_commands(sig: Signature) -> tuple[str, ...]:
17
+ """Hashcat and John wordlist commands, for the tools that support ``sig``."""
18
+ commands = []
19
+ if sig.hashcat_mode is not None:
20
+ commands.append(f"hashcat -m {sig.hashcat_mode} -a 0 {HASH_FILE} {WORDLIST}")
21
+ if sig.john_format is not None:
22
+ commands.append(f"john --format={sig.john_format} --wordlist={WORDLIST} {HASH_FILE}")
23
+ return tuple(commands)
24
+
25
+
26
+ def tool_notes(sig: Signature, value: str) -> tuple[str, ...]:
27
+ """Notes on preparing ``value`` for the tools, when it needs changes."""
28
+ if sig is MYSQL41:
29
+ if value.startswith("*"):
30
+ return (
31
+ "For hashcat, save the hash without the leading '*' (mode 300 "
32
+ "expects exactly 40 hex characters). John's mysql-sha1 format "
33
+ "needs the '*', so keep it for john.",
34
+ )
35
+ return (
36
+ "For john, add a leading '*' (its mysql-sha1 format expects '*' "
37
+ "plus 40 hex characters). Hashcat mode 300 takes the hash as is.",
38
+ )
39
+ return ()
hashident/context.py ADDED
@@ -0,0 +1,56 @@
1
+ """Context hints: where the hash was found (the --from option).
2
+
3
+ A hint boosts signatures that are typical for that environment. It never
4
+ penalises the others; it only changes which plausible format is preferred.
5
+
6
+ Boost size
7
+ ----------
8
+ ``CONTEXT_BOOST`` is 50 points on the 0-100 popularity scale, where tiers
9
+ are 20 points apart (see ``hashident.signatures``):
10
+
11
+ * It overturns a popularity gap of up to two tiers (40 points) but not
12
+ three (60). NTLM (60) beats MD5 (100) with --from windows (110 vs 100),
13
+ but a rare format (20) cannot overtake a ubiquitous one (100) on a hint
14
+ alone (70 vs 100).
15
+ * After such a flip the two are 10 points apart, well within the engine's
16
+ ``MEDIUM_MAX_GAP`` of 20, so both stay MEDIUM. A hint changes the best
17
+ guess; it does not turn an ambiguous match into a certain one.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from hashident.models import Adjustment, Context, Signature
23
+
24
+ CONTEXT_BOOST = 50.0
25
+ """Points added to a signature whose contexts include the hint."""
26
+
27
+ _DESCRIPTIONS: dict[Context, str] = {
28
+ Context.WINDOWS: "common in Windows environments",
29
+ Context.LINUX: "common in Linux environments",
30
+ Context.WEB: "common in web applications",
31
+ Context.DB: "common in databases",
32
+ }
33
+
34
+
35
+ def parse_context(value: Context | str | None) -> Context | None:
36
+ """Turn a hint like ``"Windows"`` into a ``Context``; None means no hint.
37
+
38
+ Raises ValueError, listing the valid options, for an unknown string.
39
+ """
40
+ if value is None or isinstance(value, Context):
41
+ return value
42
+ if not isinstance(value, str):
43
+ raise TypeError(f"context must be a Context, str or None, not {type(value).__name__}")
44
+ try:
45
+ return Context(value.strip().casefold())
46
+ except ValueError:
47
+ valid = ", ".join(c.value for c in Context)
48
+ raise ValueError(f"Invalid context {value!r}; choose one of: {valid}") from None
49
+
50
+
51
+ def context_adjustments(sig: Signature, context: Context | None) -> list[Adjustment]:
52
+ """The boost for ``sig`` under ``context``, if it fits; otherwise none."""
53
+ if context is None or context not in sig.contexts:
54
+ return []
55
+ reason = f"{_DESCRIPTIONS[context]} (--from {context.value})"
56
+ return [Adjustment(delta=CONTEXT_BOOST, reason=reason)]
hashident/engine.py ADDED
@@ -0,0 +1,157 @@
1
+ """Identification engine: turns a raw string into ranked candidates.
2
+
3
+ Pipeline for one input:
4
+
5
+ 1. Normalize (``Result`` strips surrounding whitespace).
6
+ 2. Match the normalized value against every signature.
7
+ 3. Score each match: popularity, then any score adjustments
8
+ (currently the context hint, see ``hashident.context``).
9
+ 4. Rank: score descending, then name, so output is deterministic.
10
+ 5. Label confidence and add reasons that depend on the other matches.
11
+
12
+ Confidence thresholds
13
+ ---------------------
14
+ * Exactly one signature matches: HIGH. The shape alone pins down the format.
15
+ * Several match: the input is genuinely ambiguous, so nothing is HIGH,
16
+ with or without a context hint.
17
+ A candidate within ``MEDIUM_MAX_GAP`` points of the top score is MEDIUM
18
+ (the top candidate always is); anything further behind is LOW.
19
+
20
+ ``MEDIUM_MAX_GAP`` is 20 because popularity tiers are 20 points apart
21
+ (see ``hashident.signatures``): a candidate within one tier of the leader
22
+ is still a serious contender, while one two or more tiers behind is a
23
+ long shot. MD5 (100) vs NTLM (60) gives MD5 MEDIUM and NTLM LOW.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from collections.abc import Sequence
30
+ from dataclasses import dataclass
31
+
32
+ from hashident.context import context_adjustments, parse_context
33
+ from hashident.models import Adjustment, Candidate, Confidence, Context, Result, Signature
34
+ from hashident.signatures import SIGNATURES
35
+
36
+ MEDIUM_MAX_GAP = 20.0
37
+ """Max points behind the top score for a candidate to still be MEDIUM."""
38
+
39
+ _HEX_RE = re.compile(r"[0-9a-fA-F]+")
40
+
41
+ # Modular crypt format identifiers whose first parameter is a cost factor.
42
+ _MCF_COST_IDS = frozenset({"2a", "2b", "2y"})
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _Scored:
47
+ signature: Signature
48
+ score: float
49
+ reasons: tuple[str, ...]
50
+
51
+
52
+ def identify(
53
+ raw: str,
54
+ context: Context | str | None = None,
55
+ *,
56
+ signatures: Sequence[Signature] = SIGNATURES,
57
+ ) -> Result:
58
+ """Identify which hash formats ``raw`` could be, best guess first.
59
+
60
+ ``context`` is an optional hint about where the hash came from, as a
61
+ ``Context`` or its name (e.g. ``"windows"``, case-insensitive). An
62
+ unknown name raises ValueError listing the valid options.
63
+
64
+ Returns a ``Result`` with no candidates for empty input or input that
65
+ matches no known format; it does not raise for those cases.
66
+ """
67
+ hint = parse_context(context)
68
+ result = Result(raw=raw, context=hint)
69
+ value = result.normalized
70
+ if not value:
71
+ return result
72
+
73
+ matched = [sig for sig in signatures if sig.matches(value)]
74
+ scored = [_score(sig, value, hint) for sig in matched]
75
+ scored.sort(key=lambda s: (-s.score, s.signature.name))
76
+
77
+ candidates = tuple(_to_candidate(s, scored, value) for s in scored)
78
+ return Result(raw=raw, candidates=candidates, context=hint)
79
+
80
+
81
+ def _score(sig: Signature, value: str, context: Context | None) -> _Scored:
82
+ """Score one matching signature: popularity plus every adjustment."""
83
+ score = float(sig.popularity)
84
+ reasons = [_shape_reason(sig, value)]
85
+ for adj in _adjustments(sig, value, context):
86
+ score += adj.delta
87
+ reasons.append(adj.reason)
88
+ return _Scored(signature=sig, score=score, reasons=tuple(reasons))
89
+
90
+
91
+ def _adjustments(sig: Signature, value: str, context: Context | None) -> list[Adjustment]:
92
+ """Score adjustments for one candidate.
93
+
94
+ This is the single place where scores are changed beyond popularity.
95
+ """
96
+ return context_adjustments(sig, context)
97
+
98
+
99
+ def _to_candidate(item: _Scored, ranked: Sequence[_Scored], value: str) -> Candidate:
100
+ """Label confidence and add reasons that depend on the other matches."""
101
+ top = ranked[0]
102
+ others = [s.signature.name for s in ranked if s is not item]
103
+ reasons = list(item.reasons)
104
+
105
+ if not others:
106
+ confidence = Confidence.HIGH
107
+ reasons.append("no other known format has this shape")
108
+ else:
109
+ gap = top.score - item.score
110
+ confidence = Confidence.MEDIUM if gap <= MEDIUM_MAX_GAP else Confidence.LOW
111
+ reasons.append(_ranking_reason(item, top, ranked))
112
+ why = "same length and charset" if _HEX_RE.fullmatch(value) else "same format"
113
+ reasons.append(f"also matches {', '.join(others)}: {why}")
114
+
115
+ return Candidate(
116
+ signature=item.signature,
117
+ score=item.score,
118
+ confidence=confidence,
119
+ reasons=tuple(reasons),
120
+ )
121
+
122
+
123
+ def _ranking_reason(item: _Scored, top: _Scored, ranked: Sequence[_Scored]) -> str:
124
+ """Explain this candidate's place in the final ranking.
125
+
126
+ Popularity comparisons are always true statements about popularity;
127
+ when an adjustment reversed the popularity order, the reason says so.
128
+ """
129
+ pop = item.signature.popularity
130
+ if item is top:
131
+ more_popular = [s.signature.name for s in ranked if s.signature.popularity > pop]
132
+ if not more_popular:
133
+ return "most common format with this shape"
134
+ return f"ranked above {', '.join(more_popular)} by the context hint"
135
+
136
+ leader, top_pop = top.signature.name, top.signature.popularity
137
+ if pop < top_pop:
138
+ return f"less common than {leader} with this shape"
139
+ if pop == top_pop and item.score == top.score:
140
+ return f"as common as {leader} with this shape"
141
+ comparison = f"more common than {leader}" if pop > top_pop else f"as common as {leader}"
142
+ return f"{comparison} with this shape, but {leader} matches the context hint"
143
+
144
+
145
+ def _shape_reason(sig: Signature, value: str) -> str:
146
+ """Describe what about the input's shape matched this signature."""
147
+ if _HEX_RE.fullmatch(value):
148
+ return f"{len(value)} hexadecimal characters"
149
+ if value.startswith("*") and _HEX_RE.fullmatch(value[1:]):
150
+ return f"'*' followed by {len(value) - 1} hexadecimal characters"
151
+ if value.startswith("$") and value.count("$") >= 2:
152
+ parts = value.split("$")
153
+ reason = f"starts with {sig.name} prefix ${parts[1]}$"
154
+ if parts[1] in _MCF_COST_IDS and len(parts) > 2 and parts[2].isdigit():
155
+ reason += f" (cost {int(parts[2])})"
156
+ return reason
157
+ return f"matches the {sig.name} format ({len(value)} characters)"
hashident/inputs.py ADDED
@@ -0,0 +1,154 @@
1
+ """Collecting the values to identify: arguments, a file, or standard input.
2
+
3
+ Order of the values, and where they come from:
4
+
5
+ 1. Positional arguments, in the order given, exactly as typed.
6
+ 2. Then the lines of ``-f PATH`` (``-f -`` reads standard input).
7
+ 3. With neither, standard input is read if it is piped or redirected. On a
8
+ terminal there is nothing to read, and ``gather`` returns None.
9
+
10
+ Lines (but not arguments) are cleaned up: surrounding whitespace and the
11
+ ``\\r`` of Windows line endings are stripped, and blank lines and lines
12
+ starting with ``#`` are skipped.
13
+
14
+ Bytes are decoded as UTF-8 unless they start with a UTF-16 byte order mark,
15
+ which is what ``>`` redirection writes in Windows PowerShell 5.1. A UTF-8
16
+ BOM is dropped. Invalid bytes become U+FFFD instead of raising.
17
+
18
+ Input is decoded in chunks and yielded one line at a time, so memory use
19
+ depends on the longest line, not on the size of the input.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import codecs
25
+ import sys
26
+ from collections.abc import Iterator, Sequence
27
+ from dataclasses import dataclass
28
+ from typing import BinaryIO, TextIO
29
+
30
+ STDIN = "-"
31
+ ARGUMENT_SOURCE = "argument"
32
+ STDIN_SOURCE = "<stdin>"
33
+
34
+ _COMMENT = "#"
35
+ _CHUNK_SIZE = 64 * 1024
36
+ _TEXT_BOM = ""
37
+ # (byte order mark, encoding of what follows it)
38
+ _BOMS = (
39
+ (codecs.BOM_UTF8, "utf-8"),
40
+ (codecs.BOM_UTF16_LE, "utf-16-le"),
41
+ (codecs.BOM_UTF16_BE, "utf-16-be"),
42
+ )
43
+ _LONGEST_BOM = max(len(bom) for bom, _ in _BOMS)
44
+
45
+
46
+ class InputError(Exception):
47
+ """Input could not be read. The message is short and meant for the user."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class InputItem:
52
+ """One value to identify, and where it came from."""
53
+
54
+ value: str
55
+ source: str
56
+ """``ARGUMENT_SOURCE``, ``STDIN_SOURCE``, or the path given to ``-f``."""
57
+ position: int
58
+ """1-based argument index, or 1-based line number within ``source``."""
59
+
60
+
61
+ def gather(values: Sequence[str], file: str | None = None) -> Iterator[InputItem] | None:
62
+ """The values to identify, in order, or None if no input was given at all.
63
+
64
+ ``file`` is opened here rather than on first iteration, so a missing or
65
+ unreadable file raises ``InputError`` before anything has been yielded.
66
+ Errors while reading raise ``InputError`` during iteration.
67
+ """
68
+ if file is None and not values:
69
+ if not _is_piped(sys.stdin):
70
+ return None
71
+ file = STDIN
72
+ if file is None:
73
+ return _items(values, "", iter(()))
74
+ source, lines = _open_lines(file)
75
+ return _items(values, source, lines)
76
+
77
+
78
+ def _items(values: Sequence[str], source: str, lines: Iterator[str]) -> Iterator[InputItem]:
79
+ for index, value in enumerate(values, start=1):
80
+ yield InputItem(value, ARGUMENT_SOURCE, index)
81
+ try:
82
+ for number, line in enumerate(lines, start=1):
83
+ value = line.strip()
84
+ if value and not value.startswith(_COMMENT):
85
+ yield InputItem(value, source, number)
86
+ except OSError as exc:
87
+ raise InputError(_cannot_read(source, exc)) from exc
88
+
89
+
90
+ def _is_piped(stream: TextIO | None) -> bool:
91
+ """Whether ``stream`` exists and is not a terminal (a pipe or a file)."""
92
+ if stream is None:
93
+ return False
94
+ try:
95
+ return not stream.isatty()
96
+ except (AttributeError, ValueError): # no isatty, or stream closed
97
+ return False
98
+
99
+
100
+ def _open_lines(file: str) -> tuple[str, Iterator[str]]:
101
+ """``(source name, raw lines)`` for a path, or for stdin if it is ``-``."""
102
+ if file == STDIN:
103
+ stream = sys.stdin
104
+ if stream is None or stream.closed:
105
+ raise InputError("cannot read standard input: it is not available")
106
+ binary = getattr(stream, "buffer", None)
107
+ # A replacement stdin such as io.StringIO has text only, no bytes.
108
+ lines = _text_lines(stream) if binary is None else _decoded_lines(binary)
109
+ return STDIN_SOURCE, lines
110
+ try:
111
+ handle = open(file, "rb")
112
+ except OSError as exc:
113
+ raise InputError(_cannot_read(file, exc)) from exc
114
+ return file, _file_lines(handle)
115
+
116
+
117
+ def _file_lines(handle: BinaryIO) -> Iterator[str]:
118
+ with handle:
119
+ yield from _decoded_lines(handle)
120
+
121
+
122
+ def _text_lines(stream: TextIO) -> Iterator[str]:
123
+ for number, line in enumerate(stream):
124
+ yield line.removeprefix(_TEXT_BOM) if number == 0 else line
125
+
126
+
127
+ def _decoded_lines(binary: BinaryIO) -> Iterator[str]:
128
+ """Decode ``binary`` chunk by chunk and yield its lines, without the ``\\n``."""
129
+ chunk = binary.read(_LONGEST_BOM)
130
+ encoding = "utf-8"
131
+ for bom, bom_encoding in _BOMS:
132
+ if chunk.startswith(bom):
133
+ encoding, chunk = bom_encoding, chunk[len(bom) :]
134
+ break
135
+ decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
136
+
137
+ pending = ""
138
+ while True:
139
+ pending += decoder.decode(chunk)
140
+ *complete, pending = pending.split("\n")
141
+ yield from complete
142
+ # read1 returns what is available, so piped lines are not held back
143
+ # until a whole chunk has arrived.
144
+ chunk = binary.read1(_CHUNK_SIZE)
145
+ if not chunk:
146
+ break
147
+ pending += decoder.decode(b"", final=True)
148
+ if pending:
149
+ yield pending
150
+
151
+
152
+ def _cannot_read(source: str, exc: OSError) -> str:
153
+ name = "standard input" if source == STDIN_SOURCE else source
154
+ return f"cannot read {name}: {exc.strerror or exc}"