blinkered 0.0.1__py3-none-any.whl

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.
blinkered/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Work on one node of a repository while the working tree shows only its dependency closure."""
blinkered/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
blinkered/checks.py ADDED
@@ -0,0 +1,91 @@
1
+ """Validation of manifests, layout and working-tree state."""
2
+ import posixpath
3
+ from pathlib import Path
4
+
5
+ from . import git
6
+ from .config import Config
7
+ from .findings import Finding
8
+ from .graph import cycles
9
+ from .manifest import Node
10
+
11
+
12
+ def ancestors(path: str) -> list[str]:
13
+ """Strict ancestor directories of `path`, excluding the repository root."""
14
+ parts = path.strip('/').split('/')
15
+ return ['/'.join(parts[:i]) for i in range(1, len(parts))]
16
+
17
+
18
+ def in_view(path: str, keep: list[str]) -> bool:
19
+ """Whether cone mode checks out `path` (directories end with `/`) when the view is `keep`."""
20
+ is_directory = path.endswith('/')
21
+ path = path.rstrip('/')
22
+ if any(path == d or path.startswith(d + '/') for d in keep):
23
+ return True
24
+ parents = {a for d in keep for a in ancestors(d)}
25
+ if is_directory:
26
+ return path in parents
27
+ parent = posixpath.dirname(path)
28
+ return parent == '' or parent in parents
29
+
30
+
31
+ def check(root: Path, config: Config, nodes: dict[str, Node],
32
+ errors: list[Finding]) -> list[Finding]:
33
+ """Manifest errors, unknown targets, cycles, layout-rule violations and plugin findings."""
34
+ findings = list(errors)
35
+ for node in nodes.values():
36
+ for kind, targets in node.edges.items():
37
+ findings += [Finding('edge', node.directory, f'{kind} → unknown node {target}')
38
+ for target in targets if target not in nodes]
39
+ findings += [Finding('cycle', nodes[cycle[0]].directory, ' → '.join(cycle))
40
+ for cycle in cycles(nodes)]
41
+ findings += layout(root, config, nodes)
42
+ if config.plugins.get('python_imports'):
43
+ from .plugins import python_imports
44
+ findings += python_imports.check(root, nodes)
45
+ return findings
46
+
47
+
48
+ def layout(root: Path, config: Config, nodes: dict[str, Node]) -> list[Finding]:
49
+ """Nested nodes and files in grouping directories outside `grouping_allow`."""
50
+ directories = {node.directory for node in nodes.values()}
51
+ findings = [Finding('layout', node.directory, f'nested inside node {a}')
52
+ for node in nodes.values() for a in ancestors(node.directory)
53
+ if a in directories]
54
+ grouping = {a for d in directories for a in ancestors(d)} - directories
55
+ files = set(git.tracked_paths(root)) | set(git.untracked_paths(root))
56
+ findings += [Finding('layout', path, 'file in grouping directory')
57
+ for path in sorted(files)
58
+ if posixpath.dirname(path) in grouping
59
+ and posixpath.basename(path) not in config.grouping_allow]
60
+ return findings
61
+
62
+
63
+ def leaving(root: Path, keep: list[str]) -> tuple[list[Finding], list[Finding]]:
64
+ """Paths that would leave a view limited to `keep`: (work at risk, ignored files git may delete).
65
+
66
+ Ignored files are at risk only inside a directory outside the view that contains tracked files.
67
+ """
68
+ tracked = git.tracked_paths(root)
69
+ work, ignored = [], []
70
+ for code, path in git.status(root):
71
+ if in_view(path, keep):
72
+ continue
73
+ if code == '!!':
74
+ outside = [a for a in ancestors(path.rstrip('/') + '/x') if not in_view(a + '/', keep)]
75
+ if any(t.startswith(a + '/') for a in outside for t in tracked):
76
+ ignored.append(Finding('ignored', path, 'would be deleted'))
77
+ elif code == '??':
78
+ work.append(Finding('untracked', path, 'would stay on disk outside the view'))
79
+ elif 'U' in code or code in ('AA', 'DD'):
80
+ work.append(Finding('conflict', path, 'unresolved'))
81
+ elif code[0] != ' ':
82
+ work.append(Finding('staged', path, 'staged change outside the view'))
83
+ else:
84
+ work.append(Finding('modified', path, 'would stay on disk outside the view'))
85
+ return work, ignored
86
+
87
+
88
+ def leaks(root: Path, keep: list[str]) -> list[Finding]:
89
+ """Non-ignored paths on disk outside the current view."""
90
+ return [Finding('leak', path, code.strip() or code) for code, path in git.status(root)
91
+ if code != '!!' and not in_view(path, keep) and (root / path).exists()]
blinkered/cli.py ADDED
@@ -0,0 +1,174 @@
1
+ """Command-line entry point."""
2
+ import argparse
3
+ import posixpath
4
+ import sys
5
+ from collections import Counter
6
+ from pathlib import Path
7
+
8
+ import blinkered
9
+ from . import checks, config as configuration, git, state
10
+ from .config import FILENAME
11
+ from .graph import closure
12
+
13
+
14
+ def csv(value: str) -> list[str]:
15
+ return [item for item in value.split(',') if item]
16
+
17
+
18
+ def parser() -> argparse.ArgumentParser:
19
+ root = argparse.ArgumentParser(prog='blinkered', description=blinkered.__doc__)
20
+ commands = root.add_subparsers(dest='command', required=True)
21
+
22
+ closure = commands.add_parser('closure', help='node and its transitive dependencies')
23
+ closure.add_argument('node')
24
+ closure.add_argument('--via', type=csv, help='edge kinds to follow, comma-separated')
25
+ closure.add_argument('--tag', type=csv, help='output only nodes with these tags')
26
+
27
+ workspace = commands.add_parser('workspace', help='narrow the working tree to a closure')
28
+ workspace.add_argument('node', nargs='?', help='new focus; omit to resync the stored focus')
29
+ workspace.add_argument('--all', action='store_true', help='restore the full working tree')
30
+ workspace.add_argument('--force', action='store_true', help='allow ignored-only paths to leave the view')
31
+
32
+ commands.add_parser('status', help='compare the view with the closure of the focus')
33
+
34
+ new = commands.add_parser('new', help='create a node and add it to the view')
35
+ new.add_argument('directory')
36
+
37
+ commands.add_parser('tags', help='tags and edge kinds in use, with counts')
38
+ commands.add_parser('check', help='manifest, graph and layout validation')
39
+ return root
40
+
41
+
42
+ class Failure(Exception):
43
+ pass
44
+
45
+
46
+ def report(title, findings):
47
+ print(title, file=sys.stderr)
48
+ for finding in findings:
49
+ print(f' {finding}', file=sys.stderr)
50
+
51
+
52
+ def view(nodes, config, focus):
53
+ if focus not in nodes:
54
+ raise Failure(f'unknown node: {focus}')
55
+ return sorted({nodes[name].directory for name in closure(nodes, focus)} | set(config.always))
56
+
57
+
58
+ def run_closure(root, config, nodes, errors, args):
59
+ if args.node not in nodes:
60
+ raise Failure(f'unknown node: {args.node}')
61
+ print('\n'.join(closure(nodes, args.node, via=args.via, tags=args.tag)))
62
+ return 0
63
+
64
+
65
+ def run_workspace(root, config, nodes, errors, args):
66
+ if args.all:
67
+ if git.sparse_patterns(root) is not None:
68
+ git.sparse_disable(root)
69
+ state.write_focus(root, None)
70
+ print('full working tree')
71
+ return 0
72
+ focus = args.node or state.read_focus(root)
73
+ if focus is None:
74
+ raise Failure('no focus: give a node')
75
+ blocking = list(errors) + checks.layout(root, config, nodes)
76
+ if blocking:
77
+ report('refusing: fix these first (blinkered check)', blocking)
78
+ return 1
79
+ keep = view(nodes, config, focus)
80
+ work, ignored = checks.leaving(root, keep)
81
+ if work or (ignored and not args.force):
82
+ report('refusing: these paths would leave the view', work + ignored)
83
+ if ignored and not work:
84
+ print('ignored files only: rerun with --force to delete them', file=sys.stderr)
85
+ return 1
86
+ git.sparse_set(root, keep)
87
+ state.write_focus(root, focus)
88
+ print(f'focus: {focus}')
89
+ print('\n'.join(f' {d}' for d in keep))
90
+ return 0
91
+
92
+
93
+ def run_status(root, config, nodes, errors, args):
94
+ focus = state.read_focus(root)
95
+ current = git.sparse_patterns(root)
96
+ if focus is None:
97
+ print('focus: none')
98
+ print('view: full' if current is None else f'view: sparse, {len(current)} directories')
99
+ return 0 if current is None else 1
100
+ expected = set(view(nodes, config, focus))
101
+ current = set(current or [])
102
+ missing, extra = sorted(expected - current), sorted(current - expected)
103
+ leaked = checks.leaks(root, sorted(current)) if current else []
104
+ print(f'focus: {focus}')
105
+ for label, items in (('missing', missing), ('extra', extra)):
106
+ for item in items:
107
+ print(f'{label}: {item}')
108
+ for finding in leaked:
109
+ print(f'leak: {finding.path} ({finding.message})')
110
+ if missing or extra or leaked:
111
+ print('→ run `blinkered workspace` to resync' if missing or extra else '→ resolve leaks')
112
+ return 1
113
+ print('in sync')
114
+ return 0
115
+
116
+
117
+ def run_new(root, config, nodes, errors, args):
118
+ directory = (Path.cwd() / args.directory).resolve().relative_to(root.resolve()).as_posix()
119
+ name = posixpath.basename(directory)
120
+ if directory in ('', '.'):
121
+ raise Failure('the repository root cannot be a node')
122
+ if name in nodes:
123
+ raise Failure(f'node {name} already exists at {nodes[name].directory}')
124
+ for node in nodes.values():
125
+ if directory.startswith(node.directory + '/'):
126
+ raise Failure(f'inside node {node.name}')
127
+ if node.directory.startswith(directory + '/'):
128
+ raise Failure(f'would contain node {node.name}')
129
+ path = root / directory
130
+ path.mkdir(parents=True, exist_ok=True)
131
+ (path / FILENAME).write_text('tags = []\n\n[edges]\n')
132
+ if git.sparse_patterns(root) is not None:
133
+ git.sparse_add(root, [directory])
134
+ print(f'created {directory}/{FILENAME}')
135
+ return 0
136
+
137
+
138
+ def run_tags(root, config, nodes, errors, args):
139
+ tags = Counter(tag for node in nodes.values() for tag in node.tags)
140
+ kinds = Counter(kind for node in nodes.values() for kind, targets in node.edges.items()
141
+ for _ in targets)
142
+ for title, counts in (('tags', tags), ('edge kinds', kinds)):
143
+ print(f'{title}:')
144
+ for item, count in counts.most_common():
145
+ print(f' {count:>4} {item}')
146
+ return 0
147
+
148
+
149
+ def run_check(root, config, nodes, errors, args):
150
+ findings = checks.check(root, config, nodes, errors)
151
+ for finding in findings:
152
+ print(finding)
153
+ print(f'{len(nodes)} nodes, {len(findings)} findings')
154
+ return 1 if findings else 0
155
+
156
+
157
+ COMMANDS = {'closure': run_closure, 'workspace': run_workspace, 'status': run_status,
158
+ 'new': run_new, 'tags': run_tags, 'check': run_check}
159
+
160
+
161
+ def main(argv: list[str] | None = None) -> int:
162
+ args = parser().parse_args(argv)
163
+ if args.command == 'workspace' and args.all and args.node:
164
+ parser().error('workspace: give a node or --all, not both')
165
+ try:
166
+ git.require_version(Path.cwd())
167
+ root = git.repo_root(Path.cwd())
168
+ config = configuration.load(root)
169
+ from .manifest import discover
170
+ nodes, errors = discover(root)
171
+ return COMMANDS[args.command](root, config, nodes, errors, args)
172
+ except (Failure, git.GitError, configuration.NotManaged, ValueError) as error:
173
+ print(f'blinkered: {error}', file=sys.stderr)
174
+ return 2
blinkered/config.py ADDED
@@ -0,0 +1,31 @@
1
+ """Repository configuration: `blinkered.toml` at the repository root."""
2
+ import tomllib
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ FILENAME = 'blinkered.toml'
7
+
8
+
9
+ class NotManaged(RuntimeError):
10
+ pass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Config:
15
+ always: tuple[str, ...] = ()
16
+ grouping_allow: tuple[str, ...] = ('README.md',)
17
+ plugins: dict[str, bool] = field(default_factory=dict)
18
+
19
+
20
+ def load(root: Path) -> Config:
21
+ """Read the root configuration; its absence means the repository is not managed."""
22
+ path = root / FILENAME
23
+ if not path.is_file():
24
+ raise NotManaged(f'no {FILENAME} at {root}')
25
+ data = tomllib.loads(path.read_text())
26
+ unknown = set(data) - {'always', 'grouping_allow', 'plugins'}
27
+ if unknown:
28
+ raise ValueError(f'{FILENAME}: unknown keys {sorted(unknown)}')
29
+ return Config(always=tuple(item.strip('/') for item in data.get('always', ())),
30
+ grouping_allow=tuple(data.get('grouping_allow', Config.grouping_allow)),
31
+ plugins=dict(data.get('plugins', {})))
blinkered/findings.py ADDED
@@ -0,0 +1,12 @@
1
+ """Problems reported by checks."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Finding:
7
+ kind: str
8
+ path: str
9
+ message: str
10
+
11
+ def __str__(self) -> str:
12
+ return f'{self.kind}: {self.path}: {self.message}'
blinkered/git.py ADDED
@@ -0,0 +1,99 @@
1
+ """Thin wrappers around the git commands blinkered relies on."""
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+
6
+ MINIMUM_VERSION = (2, 35)
7
+
8
+
9
+ class GitError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def run(root: Path, *args: str, check: bool = True) -> str:
14
+ result = subprocess.run(['git', '-C', str(root), '-c', 'advice.sparseIndexExpanded=false',
15
+ *args], capture_output=True, text=True)
16
+ if check and result.returncode:
17
+ raise GitError(result.stderr.strip() or f'git {args[0]} failed')
18
+ return result.stdout
19
+
20
+
21
+ def parse_version(text: str) -> tuple[int, ...]:
22
+ """(major, minor, patch) from `git version` output, e.g. `git version 2.50.1 (Apple Git-155)`."""
23
+ number = text.split()[2]
24
+ return tuple(int(part) for part in number.split('.')[:3] if part.isdigit())
25
+
26
+
27
+ def require_version(root: Path) -> None:
28
+ version = parse_version(run(root, 'version'))
29
+ if version < MINIMUM_VERSION:
30
+ found = '.'.join(map(str, version))
31
+ needed = '.'.join(map(str, MINIMUM_VERSION))
32
+ raise GitError(f'git {needed} or later required, found {found}')
33
+
34
+
35
+ def repo_root(start: Path) -> Path:
36
+ """Top-level directory of the repository containing `start`."""
37
+ return Path(run(start, 'rev-parse', '--show-toplevel').strip())
38
+
39
+
40
+ def git_dir(root: Path) -> Path:
41
+ """The repository's `.git` directory."""
42
+ return Path(run(root, 'rev-parse', '--absolute-git-dir').strip())
43
+
44
+
45
+ def tracked_paths(root: Path) -> list[str]:
46
+ """All paths in the index, including those outside the sparse view."""
47
+ return [path for path in run(root, 'ls-files', '-z').split('\0') if path]
48
+
49
+
50
+ def untracked_paths(root: Path) -> list[str]:
51
+ """Untracked, non-ignored files present in the working tree."""
52
+ return [path for path in run(root, 'ls-files', '-z', '--others', '--exclude-standard')
53
+ .split('\0') if path]
54
+
55
+
56
+ def read_file(root: Path, path: str) -> str:
57
+ """Working-tree content when present, otherwise the index version."""
58
+ file = root / path
59
+ if file.is_file():
60
+ return file.read_text()
61
+ return run(root, 'show', f':{path}')
62
+
63
+
64
+ def status(root: Path) -> list[tuple[str, str]]:
65
+ """(code, path) pairs from `git status --porcelain --ignored`; directories end with `/`."""
66
+ fields = run(root, 'status', '--porcelain=v1', '-z', '--ignored',
67
+ '--untracked-files=all').split('\0')
68
+ entries, i = [], 0
69
+ while i < len(fields):
70
+ field = fields[i]
71
+ i += 1
72
+ if not field:
73
+ continue
74
+ code, path = field[:2], field[3:]
75
+ entries.append((code, path))
76
+ if code[0] in 'RC':
77
+ i += 1 # the original path of a rename or copy follows
78
+ return entries
79
+
80
+
81
+ def sparse_patterns(root: Path) -> list[str] | None:
82
+ """Current cone-mode directories, or None when sparse checkout is disabled."""
83
+ if run(root, 'config', '--bool', 'core.sparseCheckout', check=False).strip() != 'true':
84
+ return None
85
+ return run(root, 'sparse-checkout', 'list').splitlines()
86
+
87
+
88
+ def sparse_set(root: Path, directories: list[str]) -> None:
89
+ """Apply a cone-mode sparse checkout of `directories`."""
90
+ run(root, 'sparse-checkout', 'set', '--cone', '--', *directories)
91
+
92
+
93
+ def sparse_add(root: Path, directories: list[str]) -> None:
94
+ run(root, 'sparse-checkout', 'add', '--', *directories)
95
+
96
+
97
+ def sparse_disable(root: Path) -> None:
98
+ """Restore the full working tree."""
99
+ run(root, 'sparse-checkout', 'disable')
blinkered/graph.py ADDED
@@ -0,0 +1,49 @@
1
+ """Traversal over the node graph."""
2
+ from collections.abc import Iterable
3
+
4
+ from .manifest import Node
5
+
6
+
7
+ def closure(nodes: dict[str, Node], start: str, *, via: Iterable[str] | None = None,
8
+ tags: Iterable[str] | None = None) -> list[str]:
9
+ """`start` and its transitive dependencies.
10
+
11
+ `via` restricts the edge kinds followed; `tags` filters the output without stopping traversal.
12
+ Unknown targets are skipped; `check` reports them.
13
+ """
14
+ if start not in nodes:
15
+ raise KeyError(start)
16
+ via = None if via is None else set(via)
17
+ seen, queue = [start], [start]
18
+ while queue:
19
+ for target in nodes[queue.pop(0)].targets(via):
20
+ if target in nodes and target not in seen:
21
+ seen.append(target)
22
+ queue.append(target)
23
+ if tags is None:
24
+ return seen
25
+ tags = set(tags)
26
+ return [name for name in seen if tags & set(nodes[name].tags)]
27
+
28
+
29
+ def cycles(nodes: dict[str, Node]) -> list[list[str]]:
30
+ """Dependency cycles, each as a list of node names."""
31
+ state, stack, found = {}, [], []
32
+
33
+ def visit(name):
34
+ state[name] = 'open'
35
+ stack.append(name)
36
+ for target in nodes[name].targets():
37
+ if target not in nodes:
38
+ continue
39
+ if state.get(target) == 'open':
40
+ found.append(stack[stack.index(target):] + [target])
41
+ elif target not in state:
42
+ visit(target)
43
+ stack.pop()
44
+ state[name] = 'done'
45
+
46
+ for name in nodes:
47
+ if name not in state:
48
+ visit(name)
49
+ return found
blinkered/manifest.py ADDED
@@ -0,0 +1,61 @@
1
+ """Node manifests: `blinkered.toml` in any directory other than the repository root."""
2
+ import posixpath
3
+ import tomllib
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from . import git
8
+ from .config import FILENAME
9
+ from .findings import Finding
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Node:
14
+ name: str
15
+ directory: str
16
+ tags: tuple[str, ...] = ()
17
+ edges: dict[str, tuple[str, ...]] = field(default_factory=dict)
18
+
19
+ def targets(self, via=None) -> list[str]:
20
+ return [target for kind, targets in self.edges.items()
21
+ if via is None or kind in via for target in targets]
22
+
23
+
24
+ def discover(root: Path) -> tuple[dict[str, Node], list[Finding]]:
25
+ """All nodes by name, read from the working tree or the index, independent of the view."""
26
+ paths = sorted(set(git.tracked_paths(root)) | set(git.untracked_paths(root)))
27
+ nodes, errors = {}, []
28
+ for path in paths:
29
+ directory, name = posixpath.split(path)
30
+ if name != FILENAME or not directory:
31
+ continue
32
+ try:
33
+ node = parse(directory, git.read_file(root, path))
34
+ except (ValueError, tomllib.TOMLDecodeError) as error:
35
+ errors.append(Finding('manifest', path, str(error)))
36
+ continue
37
+ if node.name in nodes:
38
+ errors.append(Finding('manifest', path,
39
+ f'duplicate node name, also {nodes[node.name].directory}'))
40
+ continue
41
+ nodes[node.name] = node
42
+ return nodes, errors
43
+
44
+
45
+ def parse(directory: str, text: str) -> Node:
46
+ """Build a node from its manifest text."""
47
+ data = tomllib.loads(text)
48
+ unknown = set(data) - {'tags', 'edges'}
49
+ if unknown:
50
+ raise ValueError(f'unknown keys {sorted(unknown)}')
51
+ tags = data.get('tags', [])
52
+ if not isinstance(tags, list) or not all(isinstance(tag, str) for tag in tags):
53
+ raise ValueError('tags must be a list of strings')
54
+ edges = data.get('edges', {})
55
+ if not isinstance(edges, dict):
56
+ raise ValueError('edges must be a table')
57
+ for kind, targets in edges.items():
58
+ if not isinstance(targets, list) or not all(isinstance(t, str) for t in targets):
59
+ raise ValueError(f'edges.{kind} must be a list of node names')
60
+ return Node(name=posixpath.basename(directory), directory=directory, tags=tuple(tags),
61
+ edges={kind: tuple(targets) for kind, targets in edges.items()})
@@ -0,0 +1 @@
1
+ """Optional language-specific checks, enabled under `[plugins]` in the root configuration."""
@@ -0,0 +1,48 @@
1
+ """Python code imports must be covered by declared edges.
2
+
3
+ Only files present in the working tree are inspected, so nodes outside the view are not checked.
4
+ """
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from ..findings import Finding
9
+ from ..manifest import Node
10
+
11
+
12
+ def owner(path: str, nodes: dict[str, Node]) -> Node | None:
13
+ for node in nodes.values():
14
+ if path == node.directory or path.startswith(node.directory + '/'):
15
+ return node
16
+ return None
17
+
18
+
19
+ def imported_paths(file: Path, relative: str) -> list[str]:
20
+ """Repository-relative module paths imported by `file`."""
21
+ package = relative.split('/')[:-1]
22
+ result = []
23
+ for statement in ast.walk(ast.parse(file.read_text(), filename=str(file))):
24
+ if isinstance(statement, ast.Import):
25
+ result += [alias.name.replace('.', '/') for alias in statement.names]
26
+ elif isinstance(statement, ast.ImportFrom):
27
+ if statement.level:
28
+ base = package[:len(package) - statement.level + 1]
29
+ else:
30
+ base = []
31
+ module = statement.module.split('.') if statement.module else []
32
+ result.append('/'.join(base + module))
33
+ return result
34
+
35
+
36
+ def check(root: Path, nodes: dict[str, Node]) -> list[Finding]:
37
+ """Imports of another node's directory that the importing node does not declare as an edge."""
38
+ findings = []
39
+ for node in nodes.values():
40
+ allowed = set(node.targets())
41
+ for file in sorted((root / node.directory).rglob('*.py')):
42
+ relative = file.relative_to(root).as_posix()
43
+ for path in imported_paths(file, relative):
44
+ target = owner(path, nodes)
45
+ if target and target.name != node.name and target.name not in allowed:
46
+ findings.append(Finding('import', relative,
47
+ f'imports {target.name} without a declared edge'))
48
+ return findings
blinkered/state.py ADDED
@@ -0,0 +1,23 @@
1
+ """Focus state, stored under `.git/blinkered/`."""
2
+ from pathlib import Path
3
+
4
+ from . import git
5
+
6
+
7
+ def _file(root: Path) -> Path:
8
+ return git.git_dir(root) / 'blinkered' / 'focus'
9
+
10
+
11
+ def read_focus(root: Path) -> str | None:
12
+ file = _file(root)
13
+ return file.read_text().strip() or None if file.is_file() else None
14
+
15
+
16
+ def write_focus(root: Path, node: str | None) -> None:
17
+ """Store the focus node name; None clears it."""
18
+ file = _file(root)
19
+ if node is None:
20
+ file.unlink(missing_ok=True)
21
+ return
22
+ file.parent.mkdir(parents=True, exist_ok=True)
23
+ file.write_text(node + '\n')
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: blinkered
3
+ Version: 0.0.1
4
+ Summary: Work on one node of a repository while the working tree shows only its dependency closure.
5
+ Project-URL: Homepage, https://github.com/louisantonini/blinkered
6
+ Project-URL: Repository, https://github.com/louisantonini/blinkered
7
+ Project-URL: Issues, https://github.com/louisantonini/blinkered/issues
8
+ Author: Louis Antonini
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: dependency-graph,git,monorepo,sparse-checkout,workspace
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # blinkered
24
+
25
+ Work on one node of a repository while the working tree shows only its dependency closure.
26
+
27
+ Each node is a directory with a `blinkered.toml` manifest declaring free-form tags and edges to other nodes. `blinkered workspace <node>` narrows the working tree, through git's cone-mode sparse checkout, to that node and everything it depends on. The graph can grow arbitrarily large and deep while each piece of work stays small.
28
+
29
+ ## Requirements
30
+
31
+ Python 3.11 or later and git 2.35 or later. Tested with git 2.50.
32
+
33
+ ## Usage
34
+
35
+ ```toml
36
+ # blinkered.toml at the repository root: configuration
37
+ always = ["lib"]
38
+
39
+ [plugins]
40
+ python_imports = true
41
+ ```
42
+
43
+ ```toml
44
+ # src/nodes/report/blinkered.toml: a node
45
+ tags = ["transform"]
46
+
47
+ [edges]
48
+ reads = ["selection"]
49
+ joins = ["lookup"]
50
+ ```
51
+
52
+ ```sh
53
+ blinkered check # validate manifests, graph and layout
54
+ blinkered closure report # report and its transitive dependencies
55
+ blinkered workspace report # narrow the working tree to that closure
56
+ blinkered status # compare the view with the closure after pulls or edits
57
+ blinkered workspace # resync the stored focus
58
+ blinkered workspace --all # restore the full working tree
59
+ ```
60
+
61
+ See [SPEC.md](https://github.com/louisantonini/blinkered/blob/main/SPEC.md) for the model, layout rules and full behaviour.
62
+
63
+ ## Scope
64
+
65
+ blinkered manages what the working tree shows. It does not run tests, install git hooks, use worktrees, cache data or execute nodes.
66
+
67
+ ## Caveats
68
+
69
+ - `workspace --force` deletes ignored files inside nodes leaving the view; git removes them and they cannot be recovered. Without `--force`, blinkered refuses and lists them.
70
+ - Merge conflicts in files outside the view appear on disk; resolve them with `git add --sparse`, commit, then `blinkered workspace`.
71
+ - Package `__init__.py` files must not import sibling nodes, or every import fails in a partial tree.
72
+ - Nodes cannot nest, and directories that group nodes may only hold a `README.md` by default (`grouping_allow`); other files there would be visible in every view.
73
+
74
+ ## Development
75
+
76
+ ```sh
77
+ pip install -e '.[dev]'
78
+ pytest -q
79
+ ```
@@ -0,0 +1,17 @@
1
+ blinkered/__init__.py,sha256=i8RlyfQunni31mxYnb4VOWzeLf2KEj1CngtcilVZYpQ,97
2
+ blinkered/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ blinkered/checks.py,sha256=zQY_6NUVsobQhMnmHaUBvZanmH-IuN5vN-4ybaltwVA,4100
4
+ blinkered/cli.py,sha256=5efCTmmCr5pGgOWeBUIne0WNntHoTaWENqxy2cc16jU,6684
5
+ blinkered/config.py,sha256=TGisk2zXuO6THU--i2WBlA-cfDjxvrMaynqGxXVhPlM,1069
6
+ blinkered/findings.py,sha256=VZFeiLiwWBPKVx0qSq3cVRywgj-_0ItyzxXvJTgQ4nw,245
7
+ blinkered/git.py,sha256=j9wxs8gO_QHPVB873pAdm_DjbQhortojUFD-HdnvFVQ,3453
8
+ blinkered/graph.py,sha256=kNdn1TytdXJad8e2rbDrjBvc3oxPOu4c2SPaC2aAzLI,1589
9
+ blinkered/manifest.py,sha256=k1pHb72Cx33iLRh-HLfDRSg9OjUh7hCN9vWBHmzXWSA,2422
10
+ blinkered/state.py,sha256=kcxxKxaeGYCFs1OKNDDDvuHXGYHMfVp2_DqcBB8E1U0,610
11
+ blinkered/plugins/__init__.py,sha256=DW9NnNTaZuo53jt1XJ_vmSNr3JXInAYXxxo87A7C4yE,94
12
+ blinkered/plugins/python_imports.py,sha256=_-nyM8Q0Tl6y-rufOZ21uIviZvs652tmPaf8paeV9OM,1940
13
+ blinkered-0.0.1.dist-info/METADATA,sha256=J8RGAZ_IQfzVqfjfFnwzYhDuofquObiZnbleOgzxxH0,3031
14
+ blinkered-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ blinkered-0.0.1.dist-info/entry_points.txt,sha256=tbSj_LdIkduNsdZa9rz2l0n4OFNdrj-Ul2ZJmCKQ_lg,49
16
+ blinkered-0.0.1.dist-info/licenses/LICENSE,sha256=icg8SgJcFlSwf0Kl5Cb_YFQU2y3zb-JdklS1_apol7E,1071
17
+ blinkered-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blinkered = blinkered.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Louis Antonini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.