party-continuum 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. party_continuum-0.1.0/PKG-INFO +27 -0
  2. party_continuum-0.1.0/README.md +20 -0
  3. party_continuum-0.1.0/party_continuum/__init__.py +3 -0
  4. party_continuum-0.1.0/party_continuum/applicability.py +52 -0
  5. party_continuum-0.1.0/party_continuum/build_metrics.py +72 -0
  6. party_continuum-0.1.0/party_continuum/calls.py +86 -0
  7. party_continuum-0.1.0/party_continuum/config.py +194 -0
  8. party_continuum-0.1.0/party_continuum/file_graph.py +438 -0
  9. party_continuum-0.1.0/party_continuum/graph.py +492 -0
  10. party_continuum-0.1.0/party_continuum/graph_buffer.py +192 -0
  11. party_continuum-0.1.0/party_continuum/import_resolution.py +88 -0
  12. party_continuum-0.1.0/party_continuum/local.py +444 -0
  13. party_continuum-0.1.0/party_continuum/local_cli.py +1110 -0
  14. party_continuum-0.1.0/party_continuum/occurrences.py +301 -0
  15. party_continuum-0.1.0/party_continuum/pytest_dependencies.py +173 -0
  16. party_continuum-0.1.0/party_continuum/python_aliases.py +115 -0
  17. party_continuum-0.1.0/party_continuum/relations.py +969 -0
  18. party_continuum-0.1.0/party_continuum/resolution.py +85 -0
  19. party_continuum-0.1.0/party_continuum/semantic.py +436 -0
  20. party_continuum-0.1.0/party_continuum/semantic_base.py +248 -0
  21. party_continuum-0.1.0/party_continuum/symbols.py +945 -0
  22. party_continuum-0.1.0/party_continuum/symbols_typescript.py +93 -0
  23. party_continuum-0.1.0/party_continuum/ts_extractor/extract.cjs +462 -0
  24. party_continuum-0.1.0/party_continuum/ts_extractor/package.json +10 -0
  25. party_continuum-0.1.0/party_continuum.egg-info/PKG-INFO +27 -0
  26. party_continuum-0.1.0/party_continuum.egg-info/SOURCES.txt +29 -0
  27. party_continuum-0.1.0/party_continuum.egg-info/dependency_links.txt +1 -0
  28. party_continuum-0.1.0/party_continuum.egg-info/entry_points.txt +2 -0
  29. party_continuum-0.1.0/party_continuum.egg-info/top_level.txt +1 -0
  30. party_continuum-0.1.0/pyproject.toml +21 -0
  31. party_continuum-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: party-continuum
3
+ Version: 0.1.0
4
+ Summary: Static change impact of a checkout: what depends on a symbol, how far a change reaches, which tests to run
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # continuum
9
+
10
+ Static change impact of the git checkout in the current directory, for Python
11
+ and TypeScript/JavaScript. Continuum never runs tests; it tells you which to run.
12
+
13
+ ```bash
14
+ pip install party-continuum # or: brew install maitai-inc/continuum/continuum
15
+
16
+ continuum dependents src/app/billing.py::charge # before editing: what uses it
17
+ continuum impact # after editing: how far it reaches
18
+ continuum tests --explain # before pushing: which tests, and why
19
+ ```
20
+
21
+ It compares the working tree with its merge-base with the default branch
22
+ (`--base <ref>` / `--head <commit>` to choose). Every command takes `--json`.
23
+ Suites and test commands can be pinned in a `continuum.toml` at the checkout root.
24
+
25
+ Requirements: Python 3.11+ and git. TypeScript also needs `node` and the
26
+ repository's `typescript` package installed (`npm ci`), or `CONTINUUM_TYPESCRIPT_DIR`.
27
+ No network access or credentials: everything is computed from the local checkout.
@@ -0,0 +1,20 @@
1
+ # continuum
2
+
3
+ Static change impact of the git checkout in the current directory, for Python
4
+ and TypeScript/JavaScript. Continuum never runs tests; it tells you which to run.
5
+
6
+ ```bash
7
+ pip install party-continuum # or: brew install maitai-inc/continuum/continuum
8
+
9
+ continuum dependents src/app/billing.py::charge # before editing: what uses it
10
+ continuum impact # after editing: how far it reaches
11
+ continuum tests --explain # before pushing: which tests, and why
12
+ ```
13
+
14
+ It compares the working tree with its merge-base with the default branch
15
+ (`--base <ref>` / `--head <commit>` to choose). Every command takes `--json`.
16
+ Suites and test commands can be pinned in a `continuum.toml` at the checkout root.
17
+
18
+ Requirements: Python 3.11+ and git. TypeScript also needs `node` and the
19
+ repository's `typescript` package installed (`npm ci`), or `CONTINUUM_TYPESCRIPT_DIR`.
20
+ No network access or credentials: everything is computed from the local checkout.
@@ -0,0 +1,3 @@
1
+ """Continuum change-impact CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,52 @@
1
+ """Evidence used to distinguish executable changes from file membership.
2
+
3
+ Source bytes retain their ordinary digest for storage/exploration. Selection has
4
+ an additional module-initialization fingerprint: editing a deferred function
5
+ body changes that callable, not every consumer of its containing module.
6
+ """
7
+
8
+ import ast
9
+ import copy
10
+ from pathlib import PurePosixPath
11
+
12
+ from .config import digest
13
+
14
+
15
+ def fingerprint(node):
16
+ return node.get("selection_digest", node.get("digest", node))
17
+
18
+
19
+ class Initialization(ast.NodeTransformer):
20
+ def generic_visit(self, node):
21
+ node = copy.copy(node)
22
+ for field, value in ast.iter_fields(node):
23
+ if isinstance(value, list):
24
+ setattr(node, field, list(value))
25
+ return super().generic_visit(node)
26
+
27
+ def visit_FunctionDef(self, node):
28
+ result = copy.copy(node)
29
+ # Defaults, annotations and decorators execute at definition time. The
30
+ # body executes on invocation and has its own independently changed root.
31
+ result.body = [ast.Pass()]
32
+ return result
33
+
34
+ visit_AsyncFunctionDef = visit_FunctionDef
35
+
36
+
37
+ def initialization_digest(tree):
38
+ return digest(ast.dump(Initialization().visit(tree), include_attributes=False))
39
+
40
+
41
+ def source_roots(files, config):
42
+ if "source_roots" in config:
43
+ return config["source_roots"]
44
+ roots = {""}
45
+ for path in files:
46
+ if not path.endswith("/__init__.py"):
47
+ continue
48
+ package = PurePosixPath(path).parent
49
+ if str(package.parent / "__init__.py") not in files:
50
+ parent = str(package.parent)
51
+ roots.add("" if parent == "." else parent)
52
+ return sorted(roots)
@@ -0,0 +1,72 @@
1
+ """Coarse build resource counters; no graph contents or validation authority."""
2
+
3
+ import math
4
+ import time
5
+
6
+ try:
7
+ import resource
8
+ except ImportError: # Parallel compilation also supports non-POSIX hosts.
9
+ resource = None
10
+
11
+
12
+ FIELDS = (
13
+ "cpu_seconds",
14
+ "user_cpu_seconds",
15
+ "system_cpu_seconds",
16
+ "input_blocks",
17
+ "output_blocks",
18
+ "major_faults",
19
+ "voluntary_switches",
20
+ "involuntary_switches",
21
+ )
22
+ PHASE_FIELDS = tuple(
23
+ phase + "_" + prefix + key
24
+ for phase in ("compilation", "resolution")
25
+ for prefix in (("", "reaped_child_") if phase == "compilation" else ("",))
26
+ for key in FIELDS
27
+ )
28
+
29
+
30
+ def usage():
31
+ values = {"cpu_seconds": time.process_time()}
32
+ if resource is not None:
33
+ for prefix, who in (
34
+ ("", resource.RUSAGE_SELF),
35
+ ("reaped_child_", resource.RUSAGE_CHILDREN),
36
+ ):
37
+ row = resource.getrusage(who)
38
+ values.update(
39
+ {
40
+ prefix + "cpu_seconds": row.ru_utime + row.ru_stime,
41
+ prefix + "user_cpu_seconds": row.ru_utime,
42
+ prefix + "system_cpu_seconds": row.ru_stime,
43
+ prefix + "input_blocks": row.ru_inblock,
44
+ prefix + "output_blocks": row.ru_oublock,
45
+ prefix + "major_faults": row.ru_majflt,
46
+ prefix + "voluntary_switches": row.ru_nvcsw,
47
+ prefix + "involuntary_switches": row.ru_nivcsw,
48
+ }
49
+ )
50
+ return values
51
+
52
+
53
+ def delta(before, after, phase):
54
+ # Compilation's pool has joined before sampling, so RUSAGE_CHILDREN includes
55
+ # those compilers. Resolution excludes children: the independent clean tree
56
+ # may be running concurrently, and must report its own resource counters.
57
+ return {
58
+ phase + "_" + key: after[key] - before[key]
59
+ for key in before.keys() & after.keys()
60
+ if phase == "compilation" or not key.startswith("reaped_child_")
61
+ }
62
+
63
+
64
+ def clean_metrics(stats):
65
+ values = {}
66
+ for key in PHASE_FIELDS:
67
+ if key in stats:
68
+ value = stats[key]
69
+ if type(value) not in (int, float) or not math.isfinite(value) or value < 0:
70
+ raise ValueError("invalid_build_resource_metric")
71
+ values["clean_" + key] = value
72
+ return values
@@ -0,0 +1,86 @@
1
+ """Conservative argument-to-parameter links for statically named Python callables.
2
+
3
+ Call contexts share parameter bindings: multiple call sites deliberately form a
4
+ union. Star expansion, descriptors and external callables retain uncertainty.
5
+ """
6
+
7
+ from collections import defaultdict
8
+
9
+ from .resolution import TerminalResolver
10
+
11
+
12
+ def connect_calls(documents, nodes, edges, edge, gap):
13
+ outgoing = defaultdict(list)
14
+ relations = {"binding", "definition", "import_binding", "import_member", "alias_value"}
15
+ pairs = (
16
+ edges.relation_pairs(relations)
17
+ if hasattr(edges, "relation_pairs")
18
+ else (
19
+ (item["source"], item["target"])
20
+ for item in edges.values()
21
+ if item.get("relation") in relations
22
+ )
23
+ )
24
+ for source, target in pairs:
25
+ outgoing[source].append(target)
26
+ parameters = {}
27
+ calls = []
28
+ for path, (facts, ids, bindings, module, references) in documents.items():
29
+ children = defaultdict(list)
30
+ for item in facts["syntax"]:
31
+ children[item["parent"]].append(item)
32
+ for item in facts["syntax"]:
33
+ if item["type"] in {"FunctionDef", "AsyncFunctionDef", "Lambda"}:
34
+ params = []
35
+ for child in children[item["id"]]:
36
+ if child["type"] == "arg":
37
+ symbol = bindings.get((item["id"], child["arg"]))
38
+ if symbol:
39
+ params.append((child["arg"], symbol, child["field"]))
40
+ positional = [p for p in params if p[2] in {"args", "posonlyargs"}]
41
+ keywords = defaultdict(list)
42
+ for name, symbol, kind in params:
43
+ if kind != "posonlyargs":
44
+ keywords[name].append(symbol)
45
+ parameters[ids[item["id"]]] = positional, keywords
46
+ if item["type"] == "Call":
47
+ calls.append((path, item, children, ids))
48
+ resolver = TerminalResolver(outgoing, parameters)
49
+ for path, call, children, ids in calls:
50
+ callee = next((n for n in children[call["id"]] if n["field"] == "func"), None)
51
+ if not callee:
52
+ continue
53
+ targets = resolver.resolve(ids[callee["id"]])
54
+ arguments = [n for n in children[call["id"]] if n["field"] == "args"]
55
+ keywords = [n for n in children[call["id"]] if n["field"] == "keywords"]
56
+ for target in targets:
57
+ edge(ids[call["id"]], target, "calls", resolution="potential")
58
+ positional, named = parameters[target]
59
+ for parameter, argument in zip(positional, arguments):
60
+ if argument["type"] == "Starred":
61
+ gap(path, ids[argument["id"]], "expanded_argument_binding")
62
+ break
63
+ edge(
64
+ parameter[1],
65
+ ids[argument["id"]],
66
+ "argument_value",
67
+ resolution="potential",
68
+ call=ids[call["id"]],
69
+ )
70
+ for keyword in keywords:
71
+ if not keyword.get("arg"):
72
+ gap(path, ids[keyword["id"]], "expanded_argument_binding")
73
+ continue
74
+ for symbol in named.get(keyword["arg"], ()):
75
+ edge(
76
+ symbol,
77
+ ids[keyword["id"]],
78
+ "argument_value",
79
+ resolution="potential",
80
+ call=ids[call["id"]],
81
+ )
82
+ return {
83
+ "call_resolution_requests": resolver.requests,
84
+ "call_resolution_cache_hits": resolver.hits,
85
+ "call_resolution_nodes": len(resolver.results),
86
+ }
@@ -0,0 +1,194 @@
1
+ """Explicit operator-provisioned grants; webhook payloads cannot create tenants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ _CANONICAL_ENCODER = json.JSONEncoder(sort_keys=True, separators=(",", ":"), ensure_ascii=True)
12
+ # "all" reconciles the default branch, open PR heads and merge-queue refs and
13
+ # schedules PR/merge-group event commits. "default_branch" limits automatic
14
+ # processing to the repository's current default branch: other refs and PR or
15
+ # merge-group events are retained as deliveries but never indexed.
16
+ TRACKED_REFS = frozenset({"all", "default_branch"})
17
+ INVENTORY_KEYS = frozenset(
18
+ {"api_token_env", "webhook_secret_env", "analyzer_config", "tracked_refs"}
19
+ )
20
+
21
+
22
+ def canonical(value):
23
+ return _CANONICAL_ENCODER.encode(value)
24
+
25
+
26
+ def digest(value):
27
+ return hashlib.sha256(canonical(value).encode()).hexdigest()
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Repository:
32
+ tenant: str
33
+ installation: int
34
+ repository: int
35
+ name: str
36
+ api_token_env: str
37
+ webhook_secret_env: str
38
+ github_token_env: str = ""
39
+ local_git: str = ""
40
+ default_branch: str = "main"
41
+ analyzer_config: dict = field(default_factory=dict)
42
+ tracked_refs: str = "all"
43
+
44
+ def __post_init__(self):
45
+ if self.tracked_refs not in TRACKED_REFS:
46
+ raise ValueError("invalid_tracked_refs")
47
+
48
+ @property
49
+ def default_branch_only(self):
50
+ return self.tracked_refs == "default_branch"
51
+
52
+ @property
53
+ def scope(self):
54
+ return canonical([self.tenant, self.installation, self.repository])
55
+
56
+ @property
57
+ def config_digest(self):
58
+ return digest(self.analyzer_config)
59
+
60
+
61
+ @dataclass
62
+ class Settings:
63
+ database: str
64
+ repositories: list[Repository]
65
+ app_id: str = ""
66
+ private_key_env: str = "CONTINUUM_GITHUB_PRIVATE_KEY"
67
+ reconcile_seconds: int = 60
68
+ lease_seconds: int = 300
69
+ max_attempts: int = 5
70
+ archive_directory: str = ""
71
+ compile_workers: int = 0
72
+ overlap_validation: bool = False
73
+ index_workers: int = 0
74
+ native_publication_budget_bytes: int = 8 * 1024**3
75
+ # Whole-tree source admission limits for a single commit. The defaults match
76
+ # the historical provider constants; large repositories raise them explicitly.
77
+ source_bytes_limit: int = 50 * 1024 * 1024
78
+ source_files_limit: int = 10000
79
+ # Dedicated-App inventory grants (PostgreSQL `continuum_repositories`, kept
80
+ # current by the scheduled inventory sync). Enabled rows become repository
81
+ # grants sharing this credential pair, analyzer configuration and ref policy,
82
+ # so webhook admission, worker scope and dashboard history use one scope key.
83
+ inventory: dict | None = None
84
+
85
+ def grants(self, store=None):
86
+ """Authoritative repository grants for the current operation.
87
+
88
+ Static `repositories` always apply. With `inventory` configured, enabled
89
+ inventory rows are read on every call, so newly granted repositories are
90
+ admitted and removed or suspended access stops admitting work without a
91
+ restart. Static grants take precedence over an inventory row for the
92
+ same repository ID.
93
+ """
94
+ result = list(self.repositories)
95
+ if not self.inventory or store is None:
96
+ return result
97
+ with store.connect() as db:
98
+ rows = db.execute(
99
+ "SELECT scope,repository,installation,name FROM continuum_repositories "
100
+ "WHERE enabled ORDER BY repository"
101
+ ).fetchall()
102
+ known = {repo.repository for repo in result}
103
+ for row in rows:
104
+ tenant, installation, repository = json.loads(row["scope"])
105
+ if row["repository"] in known or (installation, repository) != (
106
+ row["installation"],
107
+ row["repository"],
108
+ ):
109
+ continue # A static grant wins; an inconsistent row never becomes authority.
110
+ result.append(
111
+ Repository(
112
+ tenant,
113
+ installation,
114
+ repository,
115
+ row["name"],
116
+ self.inventory["api_token_env"],
117
+ self.inventory["webhook_secret_env"],
118
+ analyzer_config=dict(self.inventory.get("analyzer_config", {})),
119
+ tracked_refs=self.inventory.get("tracked_refs", "default_branch"),
120
+ )
121
+ )
122
+ return result
123
+
124
+ @classmethod
125
+ def load(cls):
126
+ raw = json.loads(Path(os.environ["CONTINUUM_CONFIG"]).read_text())
127
+ if os.environ.get("CONTINUUM_DATABASE_URL"):
128
+ raw["database"] = os.environ["CONTINUUM_DATABASE_URL"]
129
+ if os.environ.get("CONTINUUM_APP_ID"):
130
+ raw["app_id"] = os.environ[
131
+ "CONTINUUM_APP_ID"
132
+ ] # keeps the App identity out of config files
133
+ raw["repositories"] = [Repository(**r) for r in raw["repositories"]]
134
+ settings = cls(**raw)
135
+ if settings.inventory is not None:
136
+ inventory = settings.inventory
137
+ if (
138
+ not isinstance(inventory, dict)
139
+ or set(inventory) - INVENTORY_KEYS
140
+ or not {"api_token_env", "webhook_secret_env"} <= set(inventory)
141
+ or not all(
142
+ isinstance(inventory[key], str) and inventory[key]
143
+ for key in ("api_token_env", "webhook_secret_env")
144
+ )
145
+ or not isinstance(inventory.get("analyzer_config", {}), dict)
146
+ or inventory.get("tracked_refs", "default_branch") not in TRACKED_REFS
147
+ ):
148
+ raise ValueError("invalid_inventory_configuration")
149
+ if not settings.database.startswith(("postgresql://", "postgres://")):
150
+ raise ValueError("inventory_grants_require_postgres")
151
+ if not os.environ.get(inventory["api_token_env"]) or not os.environ.get(
152
+ inventory["webhook_secret_env"]
153
+ ):
154
+ raise ValueError("configured API and webhook credentials must be present")
155
+ if os.environ.get(inventory["api_token_env"]) in {
156
+ os.environ.get(r.api_token_env) for r in settings.repositories
157
+ }:
158
+ raise ValueError("each repository requires a distinct API credential")
159
+ if settings.archive_directory and not settings.database.startswith(
160
+ ("postgresql://", "postgres://")
161
+ ):
162
+ raise ValueError("native_archive_requires_postgres")
163
+ if type(settings.compile_workers) is not int or not 0 <= settings.compile_workers <= 8:
164
+ raise ValueError("invalid_compile_workers")
165
+ if settings.compile_workers and not settings.archive_directory:
166
+ raise ValueError("parallel_compile_requires_native_archive")
167
+ if type(settings.index_workers) is not int or not 0 <= settings.index_workers <= 8:
168
+ raise ValueError("invalid_index_workers")
169
+ if settings.index_workers and not settings.archive_directory:
170
+ raise ValueError("parallel_indexes_require_native_archive")
171
+ if type(settings.overlap_validation) is not bool or (
172
+ settings.overlap_validation and not settings.compile_workers
173
+ ):
174
+ raise ValueError("overlap_validation_requires_parallel_compile")
175
+ if settings.native_publication_budget_bytes < 1:
176
+ raise ValueError("native_publication_budget_must_be_positive")
177
+ for name in ("source_bytes_limit", "source_files_limit"):
178
+ value = getattr(settings, name)
179
+ if type(value) is not int or value < 1:
180
+ raise ValueError("invalid_" + name)
181
+ ids = [r.repository for r in settings.repositories]
182
+ if len(ids) != len(set(ids)):
183
+ raise ValueError("repository IDs must have one tenant owner")
184
+ if min(settings.reconcile_seconds, settings.lease_seconds, settings.max_attempts) < 1:
185
+ raise ValueError("worker intervals must be positive")
186
+ api_credentials = [os.environ.get(r.api_token_env, "") for r in settings.repositories]
187
+ if len(api_credentials) != len(set(api_credentials)):
188
+ raise ValueError("each repository requires a distinct API credential")
189
+ for repo in settings.repositories:
190
+ if not os.environ.get(repo.api_token_env) or not os.environ.get(
191
+ repo.webhook_secret_env
192
+ ):
193
+ raise ValueError("configured API and webhook credentials must be present")
194
+ return settings