#!/usr/bin/env python3 """Turn a Python codebase into `code`-module facts. The `code` reasoning module supplies the reasoning. This supplies the facts. It reads your source and writes a DKE Python program that records what it found — functions, classes, calls, imports — which you then submit to your store with the `compile` (or `run`) tool you already have: dke-ingest-python ./src > facts.dpy # then submit facts.dpy through your DKE client Nothing here talks to the network, and nothing here needs your key. The output is a program you can read before you run it: every fact this tool would record is a line you can see. Parsing is CPython's own `ast`, so what counts as a function here is what counts as a function to Python. No third-party parser, nothing vendored. This file is the PYTHON READER. Everything downstream of reading — subject ids, the row tables, the header, the CLI — is language-neutral and lives in `dke_ingest_core`, so a second language is a walker rather than a second copy of the emitter. ON CALL RESOLUTION — read this before trusting `uncalled()`. Python resolves calls at run time. A tool reading source can only resolve what the source makes plain, so the call graph this produces is a LOWER BOUND: every edge in it is real, and edges are missing. Calls through a variable, a dict of handlers, `getattr`, a decorator, or any dynamic dispatch are not visible here and are NOT recorded. That matters most for `uncalled()`, which reports functions with no recorded caller. An unresolved call makes its callee look uncalled. The tool prints its resolution rate to stderr and writes it into the generated program's header; treat `uncalled()` as a list of candidates to look at, never as a list of dead code to delete. """ from __future__ import annotations import ast import io import re import sys import tokenize from pathlib import Path, PurePosixPath from dke.ingest import core from dke.ingest.core import ( # noqa: F401 (re-exported: this module IS the Python ingest) MODULE, Call, Dependency, Facts, Function, Ids, Import, Klass, Language, UnresolvedCall, reached_by_tests, sanitize, ) def _call_text(func: ast.AST) -> str: """The callee expression as written, so a reader can see WHY it is here. `ast.unparse` reproduces the source shape (`handlers[k]`, `obj.method`, `getattr(x, n)`), which is exactly the thing a reviewer needs in order to judge a dispatch site. Capped, because a call target can be an arbitrarily long expression and this is a label, not a payload. """ try: text = ast.unparse(func) except Exception: # pragma: no cover — unparse is total in 3.9+ return "" return text if len(text) <= 120 else text[:117] + "..." # THE NORMATIVE COUNTING RULE, as published in the `code` reference. # # complexity = 1 + branches + loops, and the list of what counts is CLOSED: # one per `if`/`elif`, per loop, per `case` arm that tests, per exception # handler, per short-circuit boolean, per conditional expression. `else` and a # catch-all `case _` add nothing — neither is a decision, they are where # control lands when the decisions above them did not fire. # # Closed is the load-bearing word. A threshold (`hotspot` is complexity > 10) # over a number every consumer computes their own way compares nothing, and # what settled it was measuring: two of OUR producers disagreed on real code # (CODE-COMPLEXITY-COUNTING-UNDEFINED). So anything not on the list counts # nothing, even where an argument could be made for it — see `assert` below. BRANCH_NODES = (ast.If, ast.IfExp) LOOP_NODES = (ast.For, ast.AsyncFor, ast.While, ast.comprehension) # `assert` USED to count here and no longer does. It is a real decision point # by any textbook reading — `assert c` is `if not c: raise` — but it is not on # the published list, and it cannot be: C++'s `assert` is a macro, so a reader # working from source sees an ordinary call and could never agree. Counting it # in one producer and not the other is precisely the divergence the rule # exists to remove, and the rule is worth more than the one branch. def match_tests(node: ast.Match) -> int: """How many decisions a `match` makes: one per arm that TESTS. A bare `case _:` is the catch-all — control lands there when no arm above it matched, which is not a further decision, exactly as `default:` is not one in a `switch`. `case _ if cond:` DOES test, because the guard can fail. This counted 1 per `match` regardless of arm count until the rule was published. A `match` with ten arms has ten paths, so the old number was wrong on its own terms and not merely a different convention. """ n = 0 for case in node.cases: wildcard = (isinstance(case.pattern, ast.MatchAs) and case.pattern.pattern is None and case.pattern.name is None) if wildcard and case.guard is None: continue n += 1 return n def count_shape(fn: ast.AST) -> tuple[int, int]: """(branches, loops) within a function, not counting nested functions. A nested `def` is its own Function with its own counts, so descending into it would charge its complexity to the parent as well. """ branches = 0 loops = 0 class Walker(ast.NodeVisitor): def visit_FunctionDef(self, node: ast.AST) -> None: if node is not fn: return # nested def — counted as its own Function self.generic_visit(node) visit_AsyncFunctionDef = visit_FunctionDef def generic_visit(self, node: ast.AST) -> None: nonlocal branches, loops if isinstance(node, BRANCH_NODES): branches += 1 elif isinstance(node, ast.Match): branches += match_tests(node) elif isinstance(node, ast.Try): branches += len(node.handlers) elif isinstance(node, ast.BoolOp): branches += max(0, len(node.values) - 1) elif isinstance(node, LOOP_NODES): loops += 1 if isinstance(node, ast.comprehension): branches += len(node.ifs) super().generic_visit(node) Walker().visit(fn) return branches, loops # ── Tests ──────────────────────────────────────────────────────────────── # # Which functions does a test reach? The answer feeds `code.Function.test`, # which is the input `code.untested` reads — a function with lines and no test # recorded is reported as untested. Before this the ingest wrote no test facts # at all, so `untested` concluded for EVERY function it emitted: a rule that is # universally true carries no information. # # Recognition is by Python's own conventions, which are strong enough to read # without guessing. Anything looser (importing pytest, a `conftest` nearby) # would start inferring, and a wrong test edge is worse than a missing one for # exactly the reason a wrong call edge is: the queries cannot tell it from a # fact. def is_test_file(path: str) -> bool: """A file pytest/unittest would collect, by name or by directory.""" p = PurePosixPath(path.replace("\\", "/")) if p.name.startswith("test_") and p.suffix == ".py": return True if p.stem.endswith("_test") and p.suffix == ".py": return True return core.path_in_test_dir(path) def is_test_func(name: str, class_stack: list[str]) -> bool: """A test callable: `test_*` / `test`, or any method of a `Test*` class.""" if name == "test" or name.startswith("test_"): return True return bool(class_stack) and class_stack[-1].rsplit(".", 1)[-1].startswith("Test") class ModuleScanner(ast.NodeVisitor): """One file. Collects definitions, then resolves that file's calls.""" def __init__(self, module: str, path: str, facts: Facts) -> None: self.module = module self.path = path self.facts = facts self.in_test_file = is_test_file(path) # name-in-this-file → qualified name it refers to. Filled by imports # and by this module's own top-level defs; consulted when resolving. self.local: dict[str, str] = {} self.alias_modules: dict[str, str] = {} # `np` → `numpy` self.scope: list[str] = [] # enclosing def/class qualified-name parts self.class_stack: list[str] = [] # (caller qualified name, call node, enclosing class or None). The # class is captured HERE, at visit time, for the same reason `caller` # is: resolution runs after the walk, when `class_stack` has been # popped back to empty. Reading it at resolve time made the # `self.method()` branch of `_target_of` unreachable and dropped every # intra-class edge (CODE-INGEST-SELF-CALLS-NEVER-RESOLVE). self.pending_calls: list[tuple[str, ast.Call, str | None]] = [] # (enclosing function qualified name, local variable) → class qualified # name, for a local bound DIRECTLY from a constructor: `sc = Scanner()` # tells us exactly what `sc.resolve()` calls. Keyed by the enclosing # function so two functions reusing a variable name cannot borrow each # other's binding — that would be a guess, and a wrong edge is worse # than a missing one here. self.var_class: dict[tuple[str, str], str] = {} # enclosing function qualified name → its parameter names. A PARAMETER # and nothing else: it denotes a runtime value over the whole body, so # `f()` inside `def g(f)` names no function, and no flow analysis is # needed to know that. A name bound by a local ASSIGNMENT is also a # runtime value, but only after the assignment runs — treating it the # same would drop a true edge from any call that precedes the rebind, # and dropping a true edge is the failure this whole module exists to # bound. So the assignment case stays a known gap, deliberately. self.params: dict[str, set[str]] = {} # (qualified name, first line, last line) for every definition in this # file. CODE-MODULE-RATIONALE-FACTS attributes a comment by CONTAINMENT, # and `ast` has the spans while the comment scan does not — comments are # not in the tree at all, so the two have to meet on line numbers. self.spans: list[tuple[str, int, int]] = [] # ── definitions ────────────────────────────────────────────────────── def _qual(self, name: str) -> str: return ".".join([self.module] + self.scope + [name]) def _is_parameter(self, name: str, enclosing: str | None) -> bool: """Is `name` a parameter of `enclosing` or of a function around it? Walks outward so a closure over an enclosing function's parameter counts too: in `def outer(a): def inner(): a()`, `a` is still a runtime value at the call. """ if enclosing is None: return False parts = enclosing.split(".") for i in range(len(parts), 0, -1): if name in self.params.get(".".join(parts[:i]), ()): return True return False def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: qual = self._qual(node.name) branches, loops = count_shape(node) end = getattr(node, "end_lineno", node.lineno) or node.lineno self.facts.functions.append(Function( qual=qual, name=node.name, module=self.module, lines=max(1, end - node.lineno + 1), branches=branches, loops=loops, is_test=self.in_test_file and is_test_func(node.name, self.class_stack))) if not self.scope: self.local.setdefault(node.name, qual) a = node.args names = {p.arg for p in [*a.posonlyargs, *a.args, *a.kwonlyargs]} for extra in (a.vararg, a.kwarg): if extra is not None: names.add(extra.arg) self.params[qual] = names self.spans.append((qual, node.lineno, end)) self.scope.append(node.name) for child in node.body: self.visit(child) # A call in a decorator or default belongs to the enclosing scope, # not to this function's body — visited above via body only. self.scope.pop() visit_AsyncFunctionDef = visit_FunctionDef def visit_ClassDef(self, node: ast.ClassDef) -> None: qual = self._qual(node.name) methods = sum(1 for b in node.body if isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef))) self.facts.classes.append(Klass( qual=qual, name=node.name, module=self.module, methods=methods)) if not self.scope: self.local.setdefault(node.name, qual) cend = getattr(node, "end_lineno", node.lineno) or node.lineno self.spans.append((qual, node.lineno, cend)) self.scope.append(node.name) self.class_stack.append(qual) for child in node.body: self.visit(child) self.class_stack.pop() self.scope.pop() def visit_Assign(self, node: ast.Assign) -> None: # `x = SomeClass(...)` — the only binding shape narrow enough to be a # fact rather than an inference. Anything else (a parameter, a return # value, a reassignment) needs type inference to follow, and this tool # records what the source states. if (isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name) and self.scope): cls = self._target_of(node.value.func) if cls is not None: enclosing = ".".join([self.module] + self.scope) for tgt in node.targets: if isinstance(tgt, ast.Name): self.var_class[(enclosing, tgt.id)] = cls self.generic_visit(node) # ── imports ────────────────────────────────────────────────────────── def visit_Import(self, node: ast.Import) -> None: for a in node.names: self.facts.imports.append(Import(self.module, a.name)) self.alias_modules[a.asname or a.name.split(".")[0]] = a.name self.generic_visit(node) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # A relative import's module is resolved against this file's package # so `from . import x` inside `pkg.mod` records `pkg`. base = node.module or "" if node.level: parts = self.module.split(".")[:-node.level] base = ".".join([p for p in parts if p] + ([base] if base else [])) if base: self.facts.imports.append(Import(self.module, base)) for a in node.names: target = f"{base}.{a.name}" if base else a.name self.local[a.asname or a.name] = target self.generic_visit(node) # ── calls ──────────────────────────────────────────────────────────── def visit_Call(self, node: ast.Call) -> None: caller = ".".join([self.module] + self.scope) if self.scope else None if caller is not None: self.pending_calls.append( (caller, node, self.class_stack[-1] if self.class_stack else None)) self.generic_visit(node) def resolve(self, defined: set[str]) -> None: """Turn this file's call sites into edges, keeping only what resolves. `defined` is every function qualified name across the whole ingest, so a call into another ingested file resolves. A call to something outside the ingested set — a stdlib call, a third-party call, a method reached dynamically — does not resolve and is NOT recorded. An unrecorded edge is a known gap; a guessed edge would be a wrong answer, and the queries cannot tell a guess from a fact. """ for caller, node, cls in self.pending_calls: self.facts.calls_seen += 1 target = self._target_of(node.func, caller, cls) if target is None: # CODE-MODULE-UNRESOLVED-CALL-SITES — no edge is invented, but # the SITE is recorded: a call happened here and its target was # not determined. This branch only, not the one below: a target # we could not name might have been an ingested function, so it # is what bounds `uncalled()`. A target we DID name that simply # lies outside the ingested set can never suppress an ingested # function, and recording it here would pad the inventory with # sites that say nothing about that question. # # That split is only sound because `_target_of` returns None # for a call through a PARAMETER. It did not until this row: # it fabricated `.`, which lands below as an # ordinary external call — so `def indirect(fn): fn()` said # nothing, and the callback passed to it was reported uncalled # with no site to explain it. The dropped edge and the missing # site were one bug. self.facts.unresolved.append(UnresolvedCall( caller_qual=caller, site=f"{self.path}:{node.lineno}", text=_call_text(node.func))) continue if target not in defined: # CODE-INGEST-CPP-DROPS-IN-CORPUS-CONSTRUCTOR-CALLS, the # Python half. `E(1)` names the CLASS, and the ingested # function it reaches is `E.__init__` — so the target resolves # to a name that is not a Function subject and falls through # here as an external call, leaving `__init__` looking uncalled # with no site to explain it. # # Keyed on `__init__` actually being in the ingested set, so a # class with no `__init__` of its own still drops: there is no # ingested function for that construction to suppress. An # INHERITED `__init__` is not followed — that is a resolution # this reader does not do, in either language. ctor = f"{target}.__init__" if ctor not in defined: continue target = ctor self.facts.calls_resolved += 1 self.facts.calls.append(Call( caller_qual=caller, callee_qual=target, site=f"{self.path}:{node.lineno}")) def _target_of(self, func: ast.AST, enclosing: str | None = None, cls: str | None = None) -> str | None: if isinstance(func, ast.Name): # Checked BEFORE the import/def lookup, because a parameter # shadows a module-level name of the same spelling — and the call # reaches whatever was passed in, not the def that shares its name. if self._is_parameter(func.id, enclosing): return None # A name bound by `from x import f`, or a def in this module. if func.id in self.local: return self.local[func.id] return f"{self.module}.{func.id}" if isinstance(func, ast.Attribute): base = func.value if isinstance(base, ast.Name): if base.id == "self" and cls is not None: return f"{cls}.{func.attr}" # After `self`, which is a parameter but the ONE whose class # the source does state, and before the alias/import lookups a # parameter would otherwise shadow. if self._is_parameter(base.id, enclosing): return None if base.id in self.alias_modules: return f"{self.alias_modules[base.id]}.{func.attr}" if base.id in self.local: return f"{self.local[base.id]}.{func.attr}" if enclosing is not None: cls = self.var_class.get((enclosing, base.id)) if cls is not None: return f"{cls}.{func.attr}" return None return None # A requirement line reduced to (name, exact-version-or-empty, pinned). # Deliberately small: the goal is what the project DECLARED, and a full PEP 508 # parser would add a dependency to a tool whose point is having none. A line it # cannot read is skipped rather than guessed at, and the count of what was read # is reported. REQ_LINE = re.compile( r"^\s*([A-Za-z0-9._-]+)" # distribution name r"(?:\[[^\]]*\])?" # optional extras r"\s*(?:(==|>=|<=|~=|!=|>|<)\s*([^\s;,#]+))?") def parse_requirement(line: str) -> tuple[str, str, bool] | None: s = line.strip() # Comments, blank lines, flags (-r, -e, --hash), and direct URLs. A URL # requirement names no version we can trust, so it is skipped, not guessed. if not s or s.startswith("#") or s.startswith("-"): return None if "://" in s: return None m = REQ_LINE.match(s) if not m: return None name, op, ver = m.group(1), m.group(2), m.group(3) pinned = op == "==" return (name, ver if pinned else "", pinned) def read_dependencies(roots: list[Path], facts: Facts) -> None: """Read the project's DECLARED dependencies. Offline, always. Every source here is a file in the tree. A release date is NOT among the facts recorded, and that is deliberate: Python's core metadata carries no release date (checked — Name, Version, Requires-Dist and friends, nothing temporal), so an age could only come from asking an index. That would send the reader's dependency list to a third party, which is a different tool from the one documented as needing neither your key nor the network. `direct` is true for everything read here, because everything read here was declared by the project. Transitive dependencies live in lock files and are a separate reader; when one lands, its entries carry no `direct`. """ for root in roots: base = root if root.is_dir() else root.parent # requirements*.txt anywhere in the tree, plus pyproject.toml at the top. candidates = sorted(base.rglob("requirements*.txt")) pyproject = base / "pyproject.toml" if pyproject.is_file(): candidates.append(pyproject) for path in candidates: rel = str(path) if path.name == "pyproject.toml": specs = read_pyproject_requirements(path) if specs is None: continue else: try: specs = path.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeDecodeError): continue got = 0 for line in specs: parsed = parse_requirement(line) if parsed is None: continue name, ver, pinned = parsed key = name.lower() if key in facts.seen_deps: continue facts.seen_deps.add(key) facts.deps.append(Dependency(name, ver, True, pinned)) got += 1 if got: facts.dep_files.append(rel) # Locks SECOND, and deliberately: a lock lists everything that will be # installed without saying which the project asked for, so the declared set # read above is what tells them apart. for root in roots: base = root if root.is_dir() else root.parent read_lockfiles(base, set(facts.seen_deps), facts) def read_pyproject_requirements(path: Path) -> list[str] | None: """`[project] dependencies` + optional-dependencies, or None if unreadable. tomllib is stdlib from 3.11. On an older interpreter this returns None and the tool still reads requirements files — a reduced answer, not a crash. """ try: import tomllib except ImportError: return None try: data = tomllib.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, ValueError): return None project = data.get("project") or {} out: list[str] = list(project.get("dependencies") or []) for group in (project.get("optional-dependencies") or {}).values(): out.extend(group or []) return out def read_lockfiles(base: Path, declared: set[str], facts: Facts) -> None: """Add the TRANSITIVE set from a lock file, when the tree has one. A lock file lists everything that will be installed — what the project declared AND what those pulled in — and does not itself say which is which. So `declared` decides: a lock entry the project did not declare is transitive, and is recorded with NO `direct` cell rather than `direct = False`. Absence is the honest encoding, and it is what the `code` schema's reasoning already expects of a dependency nobody asked for directly. A version from a lock IS exact — that is what a lock is for — so unlike a declared range it is recorded as a fact, and the entry is marked pinned. Offline, like everything else here: a lock file is a file in the tree. No resolver is invoked and none is shelled out to. A lock that is absent stays absent; producing one would mean running a resolver, which is a network call wearing a subprocess. """ readers = ( ("poetry.lock", read_toml_packages), ("uv.lock", read_toml_packages), ("Pipfile.lock", read_pipfile_lock), ) for filename, reader in readers: path = base / filename if not path.is_file(): continue entries = reader(path) if entries is None: # Unreadable or an unexpected shape. Skipped and REPORTED — a # half-read lock would understate the tree, and silently. facts.files_failed.append((str(path), "unreadable-lock")) continue got = 0 for name, version in entries: key = name.lower() if key in declared: continue # already recorded, as direct if key in facts.seen_deps: continue facts.seen_deps.add(key) facts.deps.append(Dependency(name, version, None, bool(version))) got += 1 if got: facts.dep_files.append(str(path)) def read_toml_packages(path: Path) -> list[tuple[str, str]] | None: """poetry.lock / uv.lock — `[[package]] name = … version = …`.""" try: import tomllib except ImportError: return None try: data = tomllib.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, ValueError): return None pkgs = data.get("package") if not isinstance(pkgs, list): return None out: list[tuple[str, str]] = [] for p in pkgs: if not isinstance(p, dict): continue name = p.get("name") if isinstance(name, str) and name: ver = p.get("version") out.append((name, ver if isinstance(ver, str) else "")) return out def read_pipfile_lock(path: Path) -> list[tuple[str, str]] | None: """Pipfile.lock — JSON, `{"default": {name: {"version": "==x"}}, …}`.""" import json try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, ValueError): return None if not isinstance(data, dict): return None out: list[tuple[str, str]] = [] for section in ("default", "develop"): group = data.get(section) if not isinstance(group, dict): continue for name, spec in group.items(): ver = "" if isinstance(spec, dict): raw = spec.get("version") if isinstance(raw, str): ver = raw.lstrip("=") out.append((name, ver)) return out def scan_rationale(path: str, source: str, spans: list[tuple[str, int, int]], facts: Facts) -> None: """Record `WHY:`/`NOTE:` comments, attributed to the definition around them. CPython's `ast` discards comments entirely — they are not in the tree at any node — so this reads the SAME source a second time with `tokenize`, which keeps them and gives each a line number. That is why attribution has to meet the AST on line numbers rather than on nodes: the two readers never see the same object. A file that tokenizes badly is skipped in silence, deliberately. It has already parsed as `ast` by the time this runs, so a tokenize failure is a disagreement between two CPython readers about a file both accepted — not something a customer can act on, and not worth a line in a report about their codebase. """ try: toks = list(tokenize.generate_tokens(io.StringIO(source).readline)) except (tokenize.TokenError, IndentationError, SyntaxError): return for tok in toks: if tok.type != tokenize.COMMENT: continue body = tok.string.lstrip("#") hit = core.rationale_marker(body) if hit is None: continue marker, text = hit owner = core.enclosing_definition(tok.start[0], spans) if owner is None: continue # file-level: real, and about no subject we record facts.rationale.append(core.Rationale( subject=owner, marker=marker, text=text, site=f"{path}:{tok.start[0]}")) def module_name_for(path: Path, root: Path) -> str: rel = path.relative_to(root) parts = list(rel.parts) parts[-1] = parts[-1][:-3] # strip .py if parts[-1] == "__init__": parts.pop() return ".".join(parts) if parts else rel.stem def collect(roots: list[Path], excludes: list[str]) -> Facts: facts = Facts() scanners: list[ModuleScanner] = [] for root in roots: base = root if root.is_dir() else root.parent files = sorted(root.rglob("*.py")) if root.is_dir() else [root] for path in files: rel = str(path) # `files_found` counts the candidates the walker would read — a # file it matched and was not told to skip. It is the number that # makes "no Python here" distinguishable from "Python with nothing # in it"; a file named directly on the command line counts as # found whatever its extension, because the caller chose it. if any(x in rel for x in excludes): facts.files_excluded += 1 continue facts.files_found += 1 try: source = path.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(path)) except (SyntaxError, UnicodeDecodeError, OSError) as exc: facts.files_failed.append((rel, type(exc).__name__)) continue facts.files_parsed += 1 sc = ModuleScanner(module_name_for(path, base), rel, facts) for child in tree.body: sc.visit(child) scan_rationale(rel, source, sc.spans, facts) scanners.append(sc) # Resolution is a SECOND pass: a call into a file parsed later must still # resolve, so no edge may be decided before every definition is known. defined = {f.qual for f in facts.functions} for sc in scanners: sc.resolve(defined) # THIRD pass, and it has to be third: reachability is computed over the # RESOLVED call graph, so it cannot run before every edge is decided. facts.tested = reached_by_tests(facts) facts.test_files = sum(1 for sc in scanners if sc.in_test_file) read_dependencies(roots, facts) return facts LANG = Language( tag="python", name="Python", prog="dke-ingest-python", extensions=(".py",), caveats=[ "", "For Python that means: a call through a variable, a handler table,", "getattr, a decorator or any dispatch decided at run time is not", "visible in source and is not recorded.", ], collect=collect, ) def emit(facts: Facts, ids: Ids, *, stamp: int | None = None, load_key: str | None = None) -> str: """Render Python facts as a DKE Python program.""" return core.emit(facts, ids, LANG, stamp=stamp, load_key=load_key) def main(argv: list[str] | None = None) -> int: return core.run(LANG, argv) if __name__ == "__main__": sys.exit(main())