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:

Patch:
diff --git a/cibuildwheel/projectfiles.py b/cibuildwheel/projectfiles.py
index e006aca4..73593fc7 100644
--- 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
index e7e21ba5..b1839eda 100644
--- 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(