11import contextlib
22import os
33import pathlib
4- import re
54import shutil
65import stat
76import sys
87import zipfile
98
9+ from collections .abc import Iterable , Callable
10+
1011__all__ = ['ZipAppError' , 'create_archive' , 'get_interpreter' ]
1112
1213
@@ -75,8 +76,7 @@ def _copy_archive(archive, new_archive, interpreter=None):
7576
7677
7778def create_archive (source , target = None , interpreter = None , main = None ,
78- filter = None , compressed = False , include_pattern = None ,
79- exclude_pattern = None ):
79+ filter = None , compressed = False ):
8080 """Create an application archive from SOURCE.
8181
8282 The SOURCE can be the name of a directory, or a filename or a file-like
@@ -178,7 +178,67 @@ def get_interpreter(archive):
178178 with _maybe_open (archive , 'rb' ) as f :
179179 if f .read (2 ) == b'#!' :
180180 return f .readline ().strip ().decode (shebang_encoding )
181-
181+
182+ def _normalize_patterns (values : Iterable [str ] | None ) -> list [str ]:
183+ """
184+ Split comma-separated items, strip whitespace, drop empties.
185+ If a token has no glob metacharacters, treat it as a directory prefix:
186+ expand 'foo' into ['foo', 'foo/**'] (after normalizing slashes).
187+ """
188+ if not values :
189+ return []
190+
191+ def has_glob (s : str ) -> bool :
192+ return any (ch in s for ch in "*?[]" )
193+
194+ out : list [str ] = []
195+ for v in values :
196+ for raw in (p .strip () for p in v .split (',' )):
197+ if not raw :
198+ continue
199+ # normalize user input to POSIX-like form (match against rel.as_posix())
200+ tok = raw .replace ('\\ ' , '/' ).lstrip ('./' ).rstrip ('/' )
201+ if not tok :
202+ continue
203+ if has_glob (tok ):
204+ out .append (tok )
205+ else :
206+ # directory name implies subtree
207+ out .append (tok )
208+ out .append (f"{ tok } /**" )
209+ return out
210+
211+ def _make_glob_filter (
212+ includes : Iterable [str ] | None ,
213+ excludes : Iterable [str ] | None
214+ ) -> Callable [[pathlib .Path ], bool ]:
215+ """
216+ Build a filter(relative_path: Path) -> bool applying include first, then exclude.
217+ - Path argument is relative to source_root
218+ - Patterns are matched against POSIX-style relative paths
219+ - If includes is empty, defaults to ["**"] (include all)
220+ """
221+ inc = _normalize_patterns (includes )
222+ exc = _normalize_patterns (excludes )
223+ if not inc :
224+ inc = ["**" ]
225+
226+ def matches_any (patterns : list [str ], rel : pathlib .Path ) -> bool :
227+ posix = rel .as_posix ()
228+ # pathlib.Path.match uses glob semantics with ** (recursive)
229+ return any (rel .match (pat ) or pathlib .PurePosixPath (posix ).match (pat )
230+ for pat in patterns )
231+
232+ def _filter (rel : pathlib .Path ) -> bool :
233+ # Always work on files and directories; we'll add both. If a directory
234+ # is excluded, its children still get visited by rglob('*') but will fail here.
235+ if not matches_any (inc , rel ):
236+ return False
237+ if exc and matches_any (exc , rel ):
238+ return False
239+ return True
240+
241+ return _filter
182242
183243def main (args = None ):
184244 """Run the zipapp command line interface.
@@ -204,19 +264,14 @@ def main(args=None):
204264 "Files are stored uncompressed by default." )
205265 parser .add_argument ('--info' , default = False , action = 'store_true' ,
206266 help = "Display the interpreter from the archive." )
207- parser .add_argument ('--include-pattern' , default = None ,
208- help = (
209- "Accept a regex filtering for files to be allowed in output"
210- " archive. This will run first if `--exclude-pattern` is also used."
211- ))
212- parser .add_argument ('--exclude-pattern' , default = None ,
213- help = (
214- "Accept a regex filtering files to be denied inclusion in output"
215- " archive. This will run second if `--include-pattern` is also used."
216- " Usage example: `python -m zipapp myapp -o myapp.pyz --exclude-pattern='.*notthis.*'`"
217- ))
218267 parser .add_argument ('source' ,
219268 help = "Source directory (or existing archive)." )
269+ parser .add_argument ('--include' , action = 'extend' , nargs = '+' , default = None ,
270+ help = ("Glob pattern(s) of files/dirs to include (relative to SOURCE). "
271+ "Repeat or use commas. Defaults to '**' (everything)." ))
272+ parser .add_argument ('--exclude' , action = 'extend' , nargs = '+' , default = None ,
273+ help = ("Glob pattern(s) of files/dirs to exclude (relative to SOURCE). "
274+ "Repeat or use commas. Applied after --include." ))
220275
221276 args = parser .parse_args (args )
222277
@@ -234,14 +289,17 @@ def main(args=None):
234289 raise SystemExit ("In-place editing of archives is not supported" )
235290 if args .main :
236291 raise SystemExit ("Cannot change the main function when copying" )
237-
238- include_pattern = re .compile (args .include_pattern ) if args .include_pattern else None
239- exclude_pattern = re .compile (args .exclude_pattern ) if args .exclude_pattern else None
292+
293+ # build a filter from include and exclude flags
294+ filter_fn = None
295+ src_path = pathlib .Path (args .source )
296+ if src_path .exists () and src_path .is_dir ():
297+ filter_fn = _make_glob_filter (args .include , args .exclude )
240298
241299 create_archive (args .source , args .output ,
242300 interpreter = args .python , main = args .main ,
243- compressed = args .compress , include_pattern = include_pattern ,
244- exclude_pattern = exclude_pattern )
301+ compressed = args .compress ,
302+ filter = filter_fn )
245303
246304
247305if __name__ == '__main__' :
0 commit comments