Repository: pypa/cibuildwheel

Issue:
setup.py setup() not detected in __name__ == '__main__' block
### Description

My setup.py setup() includes:

    python_requires=">=3.8"

However cibuildwheel still tries and fails to compile under Python 3.6.

I understand there is [CIBW_BUILD / CIBW_SKIP](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip) but that is then duplicating the python requires information.

I can add a \[project\] section to pyproject.toml but that leads to a lot of problems because it ends up fighting with setup() parameters and they **really** don't like it.

I believe cibuildwheel should establish the Python version support automatically whether it comes from setuptools or pyproject.toml, and not try to build on unsupported versions.  My [pyproject.toml](https://github.com/rogerbinns/apsw/blob/master/pyproject.toml) is:

````
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
````


### Build log

https://github.com/rogerbinns/apsw/actions/runs/6175182758/job/16761477543

### CI config

https://github.com/rogerbinns/apsw/actions/runs/6175182758/workflow


Discussion:
This requires parsing the AST and trying to figure out this setting. Cibuildwheel actually does exactly this (though some people dislike it). You have a non-standard `if __name__ == "__main__"` wrapping your setup call, which I assume is what is confusing cibuildwheel's static investigation of your setup.py.

What I'd highly recommend is moving as many of these keyword arguments as possible out of your setup.py's `setup()` call and putting them in `setup.cfg`. (Or pyproject.toml, but it sounded like you didn't want to do that, that makes a bigger differences in defaults and it also requires setuptools >=61 or better 62). I'd also try to use setuptool's mechanisms for reading the README, etc - the less custom code you have in setup.py, the better.

Of course, the simplest fix would be to remove the if statement.

PS: setuptools has deprecated running tests with setup.py, so you could at least drop your test command code.
I'll leave out the name == main bit and see what happens.  Just found that CIBW_PROJECT_REQUIRES_PYTHON doc says this should work ..
Again, I'd _highly_ recommend moving all the static config to setup.cfg (remove from setup call, add to setup.cfg). This makes parsing it much easier for tooling like cibuildwheel. But if you remove the if statement, it should work, and it's probably a cibuildwheel bug if it doesn't (parsing arbitrary AST is hard).
Removing the if name == main did make cibuildwheel pick up the python_requires bit.  It is going to cause me other problems because some of my other tooling uses functions from setup.py and is going to require some ugly monkey patching to prevent setup() from running.   It would be really nice if cibuildwheel ast parsing allows the name == main if statement.  

The test command code is not there for setuptools use, as in that is not the usual way tests are run.  It is there as an arbitrary command to help with test configuration.  For example a build option can say that a particular SQLite extension should be enabled, and when test runs from the same process it can verify that extension is actually present.  When the test suite is run separately it auto-detects which extensions are present, but if there is a bug in setup.py where it is told to include an extension, and doesn't then that won't be caught.

Putting stuff in setup.cfg has problems like in #1487 and then means the information is now in two places.  Additionally setup.cfg is also there for the end users and provides a place for them to specify additional compilation options such as defines and libraries.  (As in a generic thing that has been part of distutils forever.)

I did try moving stuff to pyproject.toml \[project\] and it doesn't work because setuptools then starts complaining a lot, as I play a game of whack a mole moving the various keys and values.

The fundamental issue here is that cibuildwheel is not allowing for a name == main block containing the setup call.  I'll rename the ticket.  The suggested fixes are one of:

* Close as *wontfix*
* Close by documenting the name == main limitation in CIBW_PROJECT_REQUIRES_PYTHON section
* Make name == main work

Resolving patch:
diff --git a/cibuildwheel/projectfiles.py b/cibuildwheel/projectfiles.py
--- a/cibuildwheel/projectfiles.py
+++ b/cibuildwheel/projectfiles.py
@@ -8,6 +8,43 @@
 from ._compat import tomllib
 
 
+def get_parent(node: ast.AST | None, depth: int = 1) -> ast.AST | None:
+    for _ in range(depth):
+        node = getattr(node, "parent", None)
+    return node
+
+
+def is_main(parent: ast.AST | None) -> bool:
+    if parent is None:
+        return False
+
+    # This would be much nicer with 3.10's pattern matching!
+    if not isinstance(parent, ast.If):
+        return False
+    if not isinstance(parent.test, ast.Compare):
+        return False
+
+    try:
+        (op,) = parent.test.ops
+        (comp,) = parent.test.comparators
+    except ValueError:
+        return False
+
+    if not isinstance(op, ast.Eq):
+        return False
+
+    values = {comp, parent.test.left}
+
+    mains = {x for x in values if isinstance(x, ast.Constant) and x.value == "__main__"}
+    if len(mains) != 1:
+        return False
+    consts = {x for x in values if isinstance(x, ast.Name) and x.id == "__name__"}
+    if len(consts) != 1:
+        return False
+
+    return True
+
+
 class Analyzer(ast.NodeVisitor):
     def __init__(self) -> None:
         self.requires_python: str | None = None
@@ -19,13 +56,22 @@ def visit(self, node: ast.AST) -> None:
         super().visit(node)
 
     def visit_keyword(self, node: ast.keyword) -> None:
+        # Must not be nested except for if __name__ == "__main__"
+
         self.generic_visit(node)
-        # Must not be nested in an if or other structure
         # This will be Module -> Expr -> Call -> keyword
+        parent = get_parent(node, 4)
+        unnested = parent is None
+
+        # This will be Module -> If -> Expr -> Call -> keyword
+        name_main_unnested = (
+            parent is not None and get_parent(parent) is None and is_main(get_parent(node, 3))
+        )
+
         if (
             node.arg == "python_requires"
-            and not hasattr(node.parent.parent.parent, "parent")  # type: ignore[attr-defined]
             and isinstance(node.value, ast.Constant)
+            and (unnested or name_main_unnested)
         ):
             self.requires_python = node.value.value
 


Test patch:
diff --git a/unit_test/projectfiles_test.py b/unit_test/projectfiles_test.py
--- a/unit_test/projectfiles_test.py
+++ b/unit_test/projectfiles_test.py
@@ -26,6 +26,72 @@ def test_read_setup_py_simple(tmp_path):
     assert get_requires_python_str(tmp_path) == "1.23"
 
 
+def test_read_setup_py_if_main(tmp_path):
+    with open(tmp_path / "setup.py", "w") as f:
+        f.write(
+            dedent(
+                """
+                from setuptools import setup
+
+                if __name__ == "__main__":
+                    setup(
+                        name = "hello",
+                        other = 23,
+                        example = ["item", "other"],
+                        python_requires = "1.23",
+                    )
+                """
+            )
+        )
+
+    assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.23"
+    assert get_requires_python_str(tmp_path) == "1.23"
+
+
+def test_read_setup_py_if_main_reversed(tmp_path):
+    with open(tmp_path / "setup.py", "w") as f:
+        f.write(
+            dedent(
+                """
+                from setuptools import setup
+
+                if "__main__" == __name__:
+                    setup(
+                        name = "hello",
+                        other = 23,
+                        example = ["item", "other"],
+                        python_requires = "1.23",
+                    )
+                """
+            )
+        )
+
+    assert setup_py_python_requires(tmp_path.joinpath("setup.py").read_text()) == "1.23"
+    assert get_requires_python_str(tmp_path) == "1.23"
+
+
+def test_read_setup_py_if_invalid(tmp_path):
+    with open(tmp_path / "setup.py", "w") as f:
+        f.write(
+            dedent(
+                """
+                from setuptools import setup
+
+                if True:
+                    setup(
+                        name = "hello",
+                        other = 23,
+                        example = ["item", "other"],
+                        python_requires = "1.23",
+                    )
+                """
+            )
+        )
+
+    assert not setup_py_python_requires(tmp_path.joinpath("setup.py").read_text())
+    assert not get_requires_python_str(tmp_path)
+
+
 def test_read_setup_py_full(tmp_path):
     with open(tmp_path / "setup.py", "w", encoding="utf8") as f:
         f.write(