Repository: cython/cython

Issue:
[BUG] cython and cythonize commands does not error out when file does not exist
**Describe the bug**
All standard linux commands print out error when file is not present:

```
$ ls missing_file
ls: missing_file: No such file or directory
$ cat missing_file
cat: missing_file: No such file or directory
$ python3.9 missing_file
/usr/local/bin/python3.9: can't open file '/Users/matus/missing_file': [Errno 2] No such file or directory
```

cython and cythonize commands do not follow this semantics:
```
$ cython missing_file
/Users/matus/dev/cython/missing_file

$ cythonize missing_file
$
```

**To Reproduce**

Run `cython` or `cythonize` commands with non-existing files

**Expected behavior**

Error is printed out as in other standard tools

**Environment (please complete the following information):**
 - OS: MacOS
 - Python version 3.9.9
 - Cython version: master



Discussion:

Resolving patch:
diff --git a/Cython/Build/Cythonize.py b/Cython/Build/Cythonize.py
--- a/Cython/Build/Cythonize.py
+++ b/Cython/Build/Cythonize.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python
 
-from __future__ import absolute_import
+from __future__ import absolute_import, print_function
 
 import os
 import shutil
@@ -45,10 +45,12 @@ def find_package_base(path):
         package_path = '%s/%s' % (parent, package_path)
     return base_dir, package_path
 
-
 def cython_compile(path_pattern, options):
-    pool = None
     all_paths = map(os.path.abspath, extended_iglob(path_pattern))
+    _cython_compile_files(all_paths, options)
+
+def _cython_compile_files(all_paths, options):
+    pool = None
     try:
         for path in all_paths:
             if options.build_inplace:
@@ -230,8 +232,15 @@ def parse_args(args):
 def main(args=None):
     options, paths = parse_args(args)
 
+    all_paths = []
     for path in paths:
-        cython_compile(path, options)
+        expanded_path = [os.path.abspath(p) for p in extended_iglob(path)]
+        if not expanded_path:
+            import sys
+            print("{}: No such file or directory: '{}'".format(sys.argv[0], path), file=sys.stderr)
+            sys.exit(1)
+        all_paths.extend(expanded_path)
+    _cython_compile_files(all_paths, options)
 
 
 if __name__ == '__main__':
diff --git a/Cython/Compiler/CmdLine.py b/Cython/Compiler/CmdLine.py
--- a/Cython/Compiler/CmdLine.py
+++ b/Cython/Compiler/CmdLine.py
@@ -4,11 +4,17 @@
 
 from __future__ import absolute_import
 
+import sys
 import os
 from argparse import ArgumentParser, Action, SUPPRESS
 from . import Options
 
 
+if sys.version_info < (3, 3):
+    # TODO: This workaround can be removed in Cython 3.1
+    FileNotFoundError = IOError
+
+
 class ParseDirectivesAction(Action):
     def __call__(self, parser, namespace, values, option_string=None):
         old_directives = dict(getattr(namespace, self.dest,
@@ -209,6 +215,10 @@ def filter_out_embed_options(args):
 def parse_command_line(args):
     parser = create_cython_argparser()
     arguments, sources = parse_command_line_raw(parser, args)
+    for source in sources:
+        if not os.path.exists(source):
+            import errno
+            raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), source)
 
     options = Options.CompilationOptions(Options.default_options)
     for name, value in vars(arguments).items():
diff --git a/Cython/Compiler/Main.py b/Cython/Compiler/Main.py
--- a/Cython/Compiler/Main.py
+++ b/Cython/Compiler/Main.py
@@ -2,7 +2,7 @@
 #   Cython Top Level
 #
 
-from __future__ import absolute_import
+from __future__ import absolute_import, print_function
 
 import os
 import re
@@ -747,7 +747,16 @@ def main(command_line = 0):
     args = sys.argv[1:]
     any_failures = 0
     if command_line:
-        options, sources = parse_command_line(args)
+        try:
+            options, sources = parse_command_line(args)
+        except IOError as e:
+            # TODO: IOError can be replaced with FileNotFoundError in Cython 3.1
+            import errno
+            if errno.ENOENT != e.errno:
+                # Raised IOError is not caused by missing file.
+                raise
+            print("{}: No such file or directory: '{}'".format(sys.argv[0], e.filename), file=sys.stderr)
+            sys.exit(1)
     else:
         options = CompilationOptions(default_options)
         sources = args


Test patch:
diff --git a/Cython/Compiler/Tests/TestCmdLine.py b/Cython/Compiler/Tests/TestCmdLine.py
--- a/Cython/Compiler/Tests/TestCmdLine.py
+++ b/Cython/Compiler/Tests/TestCmdLine.py
@@ -2,6 +2,10 @@
 import sys
 import re
 from unittest import TestCase
+try:
+    from unittest.mock import patch, Mock
+except ImportError:  # Py2
+    from mock import patch, Mock
 try:
     from StringIO import StringIO
 except ImportError:
@@ -12,7 +16,15 @@
 
 from .Utils import backup_Options, restore_Options, check_global_options
 
+unpatched_exists = os.path.exists
+
+def patched_exists(path):
+    # avoid the Cython command raising a file not found error
+    if path in ('source.pyx', 'file.pyx', 'file1.pyx', 'file2.pyx', 'file3.pyx', 'foo.pyx', 'bar.pyx'):
+        return True
+    return unpatched_exists(path)
 
+@patch('os.path.exists', new=Mock(side_effect=patched_exists))
 class CmdLineParserTest(TestCase):
     def setUp(self):
         self._options_backup = backup_Options()
diff --git a/tests/run/cython_no_files.srctree b/tests/run/cython_no_files.srctree
new file mode 100644
--- /dev/null
+++ b/tests/run/cython_no_files.srctree
@@ -0,0 +1,34 @@
+PYTHON test_cythonize_no_files.py
+PYTHON test_cython_no_files.py
+
+######## a.py ###########
+a = 1
+
+######## b.py ###########
+b = 2
+
+######## c.pyx ###########
+c = 3
+
+######## d.pyx ###########
+d = 4
+
+######## test_cythonize_no_files.py ###########
+import subprocess
+import sys
+
+cmd = [sys.executable, '-c', 'from Cython.Build.Cythonize import main; main()', 'a.py', 'b.py', 'c.py', '*.pyx']
+proc = subprocess.Popen(cmd, stderr=subprocess.PIPE)
+_, err = proc.communicate()
+assert proc.returncode == 1, proc.returncode
+assert b"No such file or directory: 'c.py'" in err, err
+
+######## test_cython_no_files.py ###########
+import subprocess
+import sys
+
+cmd = [sys.executable, '-c', 'from Cython.Compiler.Main import main; main(command_line = 1)', 'a.py', 'b.py', 'c.py', '*.pyx']
+proc = subprocess.Popen(cmd, stderr=subprocess.PIPE)
+_, err = proc.communicate()
+assert proc.returncode == 1, proc.returncode
+assert b"No such file or directory: 'c.py'" in err, err