55import contextlib
66import itertools
77import logging
8+ import os .path
89from pathlib import Path
910import shutil
1011import sys
@@ -52,12 +53,26 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
5253 renderer_warning_printer = RendererWarningPrinter ()
5354 for path in file_paths :
5455 try :
55- toml_opts = read_toml_opts (path .parent if path else Path .cwd ())
56+ toml_opts , toml_path = read_toml_opts (path .parent if path else Path .cwd ())
5657 except InvalidConfError as e :
5758 print_error (str (e ))
5859 return 1
5960 opts : Mapping = {** DEFAULT_OPTS , ** toml_opts , ** cli_opts }
6061
62+ if sys .version_info >= (3 , 13 ):
63+ if is_excluded (path , opts ["exclude" ], toml_path , "exclude" in cli_opts ):
64+ continue
65+ else :
66+ if "exclude" in toml_opts :
67+ print_error (
68+ "'exclude' patterns are only available on Python 3.13+." ,
69+ paragraphs = [
70+ "Please remove the 'exclude' list from your .mdformat.toml"
71+ " or upgrade Python version."
72+ ],
73+ )
74+ return 1
75+
6176 if path :
6277 path_str = str (path )
6378 # Unlike `path.read_text(encoding="utf-8")`, this preserves
@@ -157,6 +172,12 @@ def make_arg_parser(
157172 choices = ("lf" , "crlf" , "keep" ),
158173 help = "output file line ending mode (default: lf)" ,
159174 )
175+ if sys .version_info >= (3 , 13 ):
176+ parser .add_argument (
177+ "--exclude" ,
178+ action = "append" ,
179+ help = "exclude files that match the pattern (multiple allowed)" ,
180+ )
160181 for plugin in parser_extensions .values ():
161182 if hasattr (plugin , "add_cli_options" ):
162183 plugin .add_cli_options (parser )
@@ -173,7 +194,7 @@ def __init__(self, path: Path):
173194def resolve_file_paths (path_strings : Iterable [str ]) -> list [None | Path ]:
174195 """Resolve pathlib.Path objects from filepath strings.
175196
176- Convert path strings to pathlib.Path objects. Resolve symlinks.
197+ Convert path strings to pathlib.Path objects.
177198 Check that all paths are either files, directories or stdin. If not,
178199 raise InvalidPath. Resolve directory paths to a list of file paths
179200 (ending with ".md").
@@ -184,23 +205,48 @@ def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]:
184205 file_paths .append (None )
185206 continue
186207 path_obj = Path (path_str )
187- path_obj = _resolve_path (path_obj )
208+ path_obj = _normalize_path (path_obj )
188209 if path_obj .is_dir ():
189210 for p in path_obj .glob ("**/*.md" ):
190- p = _resolve_path (p )
191- file_paths .append (p )
192- else :
211+ if p .is_file ():
212+ p = _normalize_path (p )
213+ file_paths .append (p )
214+ elif path_obj .is_file ():
193215 file_paths .append (path_obj )
216+ else :
217+ raise InvalidPath (path_obj )
194218 return file_paths
195219
196220
197- def _resolve_path (path : Path ) -> Path :
198- """Resolve path.
221+ def is_excluded (
222+ path : Path | None , patterns : list [str ], toml_path : Path | None , excludes_from_cli : bool
223+ ) -> bool :
224+ if not path :
225+ return False
226+
227+ if not excludes_from_cli and toml_path :
228+ exclude_root = toml_path .parent
229+ else :
230+ exclude_root = Path .cwd ()
231+
232+ try :
233+ relative_path = path .relative_to (exclude_root )
234+ except ValueError :
235+ return False
236+
237+ return any (relative_path .full_match (pattern ) for pattern in patterns )
238+
239+
240+ def _normalize_path (path : Path ) -> Path :
241+ """Normalize path.
199242
200- Resolve symlinks. Raise `InvalidPath` if the path does not exist.
243+ Make the path absolute, resolve any ".." sequences.
244+ Do not resolve symlinks, as it would interfere with
245+ 'exclude' patterns.
246+ Raise `InvalidPath` if the path does not exist.
201247 """
248+ path = Path (os .path .abspath (path ))
202249 try :
203- path = path .resolve () # resolve symlinks
204250 path_exists = path .exists ()
205251 except OSError : # Catch "OSError: [WinError 123]" on Windows # pragma: no cover
206252 path_exists = False
0 commit comments