bingo-light 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +522 -0
- package/README.zh-CN.md +534 -0
- package/bin/cli.js +46 -0
- package/bin/mcp.js +45 -0
- package/bingo-light +1094 -0
- package/bingo_core/__init__.py +77 -0
- package/bingo_core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/_entry.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/config.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/exceptions.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/git.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/models.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/repo.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/setup.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/state.cpython-313.pyc +0 -0
- package/bingo_core/config.py +110 -0
- package/bingo_core/exceptions.py +48 -0
- package/bingo_core/git.py +194 -0
- package/bingo_core/models.py +37 -0
- package/bingo_core/repo.py +2376 -0
- package/bingo_core/setup.py +549 -0
- package/bingo_core/state.py +306 -0
- package/completions/bingo-light.bash +118 -0
- package/completions/bingo-light.fish +197 -0
- package/completions/bingo-light.zsh +169 -0
- package/mcp-server.py +788 -0
- package/package.json +34 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core — Complete Python core library for bingo-light.
|
|
3
|
+
|
|
4
|
+
AI-native fork maintenance: manages customizations as a clean patch stack
|
|
5
|
+
on top of upstream. Every public method returns a dict with {"ok": True, ...}
|
|
6
|
+
or raises BingoError.
|
|
7
|
+
|
|
8
|
+
Python 3.8+ stdlib only. No pip dependencies.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# --- Constants ---
|
|
16
|
+
|
|
17
|
+
VERSION = "2.0.0"
|
|
18
|
+
PATCH_PREFIX = "[bl]"
|
|
19
|
+
CONFIG_FILE = ".bingolight"
|
|
20
|
+
BINGO_DIR = ".bingo"
|
|
21
|
+
DEFAULT_TRACKING = "upstream-tracking"
|
|
22
|
+
DEFAULT_PATCHES = "bingo-patches"
|
|
23
|
+
MAX_PATCHES = 100
|
|
24
|
+
MAX_DIFF_SIZE = 50000
|
|
25
|
+
PATCH_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
26
|
+
PATCH_NAME_MAX = 100
|
|
27
|
+
CIRCUIT_BREAKER_LIMIT = 3
|
|
28
|
+
RERERE_MAX_ITER = 50
|
|
29
|
+
MAX_RESOLVE_ITER = 20
|
|
30
|
+
SYNC_HISTORY_MAX = 50
|
|
31
|
+
|
|
32
|
+
# --- Re-exports (keep `from bingo_core import X` working) ---
|
|
33
|
+
|
|
34
|
+
from bingo_core.exceptions import ( # noqa: E402
|
|
35
|
+
BingoError,
|
|
36
|
+
GitError,
|
|
37
|
+
NotGitRepoError,
|
|
38
|
+
NotInitializedError,
|
|
39
|
+
DirtyTreeError,
|
|
40
|
+
)
|
|
41
|
+
from bingo_core.models import PatchInfo, ConflictInfo # noqa: E402
|
|
42
|
+
from bingo_core.git import Git # noqa: E402
|
|
43
|
+
from bingo_core.config import Config # noqa: E402
|
|
44
|
+
from bingo_core.state import State # noqa: E402
|
|
45
|
+
from bingo_core.repo import Repo # noqa: E402
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Constants
|
|
49
|
+
"VERSION",
|
|
50
|
+
"PATCH_PREFIX",
|
|
51
|
+
"CONFIG_FILE",
|
|
52
|
+
"BINGO_DIR",
|
|
53
|
+
"DEFAULT_TRACKING",
|
|
54
|
+
"DEFAULT_PATCHES",
|
|
55
|
+
"MAX_PATCHES",
|
|
56
|
+
"MAX_DIFF_SIZE",
|
|
57
|
+
"PATCH_NAME_RE",
|
|
58
|
+
"PATCH_NAME_MAX",
|
|
59
|
+
"CIRCUIT_BREAKER_LIMIT",
|
|
60
|
+
"RERERE_MAX_ITER",
|
|
61
|
+
"MAX_RESOLVE_ITER",
|
|
62
|
+
"SYNC_HISTORY_MAX",
|
|
63
|
+
# Exceptions
|
|
64
|
+
"BingoError",
|
|
65
|
+
"GitError",
|
|
66
|
+
"NotGitRepoError",
|
|
67
|
+
"NotInitializedError",
|
|
68
|
+
"DirtyTreeError",
|
|
69
|
+
# Data classes
|
|
70
|
+
"PatchInfo",
|
|
71
|
+
"ConflictInfo",
|
|
72
|
+
# Classes
|
|
73
|
+
"Git",
|
|
74
|
+
"Config",
|
|
75
|
+
"State",
|
|
76
|
+
"Repo",
|
|
77
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core.config — Configuration management for bingo-light.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from bingo_core import CONFIG_FILE, DEFAULT_PATCHES, DEFAULT_TRACKING
|
|
12
|
+
from bingo_core.exceptions import NotInitializedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Config:
|
|
16
|
+
"""Manages the .bingolight configuration file (git config format)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo_dir: str):
|
|
19
|
+
self.repo_dir = repo_dir
|
|
20
|
+
self.config_path = os.path.join(repo_dir, CONFIG_FILE)
|
|
21
|
+
|
|
22
|
+
def exists(self) -> bool:
|
|
23
|
+
"""Check if config file exists."""
|
|
24
|
+
return os.path.isfile(self.config_path)
|
|
25
|
+
|
|
26
|
+
def load(self) -> dict:
|
|
27
|
+
"""Load all bingo-light config values.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
dict with upstream_url, upstream_branch, patches_branch, tracking_branch
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
NotInitializedError: if config file doesn't exist
|
|
34
|
+
"""
|
|
35
|
+
if not self.exists():
|
|
36
|
+
raise NotInitializedError()
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"upstream_url": self.get("upstream-url") or "",
|
|
40
|
+
"upstream_branch": self.get("upstream-branch") or "main",
|
|
41
|
+
"patches_branch": self.get("patches-branch") or DEFAULT_PATCHES,
|
|
42
|
+
"tracking_branch": self.get("tracking-branch") or DEFAULT_TRACKING,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def save(
|
|
46
|
+
self,
|
|
47
|
+
url: str,
|
|
48
|
+
branch: str,
|
|
49
|
+
patches_branch: str = DEFAULT_PATCHES,
|
|
50
|
+
tracking_branch: str = DEFAULT_TRACKING,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Write config values."""
|
|
53
|
+
self.set("upstream-url", url)
|
|
54
|
+
self.set("upstream-branch", branch)
|
|
55
|
+
self.set("patches-branch", patches_branch)
|
|
56
|
+
self.set("tracking-branch", tracking_branch)
|
|
57
|
+
|
|
58
|
+
def get(self, key: str) -> Optional[str]:
|
|
59
|
+
"""Get a config value. Returns None if not found."""
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "config", "--file", self.config_path, f"bingolight.{key}"],
|
|
63
|
+
cwd=self.repo_dir,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
if result.returncode == 0:
|
|
68
|
+
return result.stdout.strip()
|
|
69
|
+
# Try without bingolight prefix
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
["git", "config", "--file", self.config_path, key],
|
|
72
|
+
cwd=self.repo_dir,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
)
|
|
76
|
+
if result.returncode == 0:
|
|
77
|
+
return result.stdout.strip()
|
|
78
|
+
return None
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def set(self, key: str, value: str) -> None:
|
|
83
|
+
"""Set a config value."""
|
|
84
|
+
subprocess.run(
|
|
85
|
+
["git", "config", "--file", self.config_path, f"bingolight.{key}", value],
|
|
86
|
+
cwd=self.repo_dir,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
check=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def list_all(self) -> dict:
|
|
93
|
+
"""List all config values as a dict."""
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git", "config", "--file", self.config_path, "--list"],
|
|
97
|
+
cwd=self.repo_dir,
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
)
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
return {}
|
|
103
|
+
items = {}
|
|
104
|
+
for line in result.stdout.strip().splitlines():
|
|
105
|
+
if "=" in line:
|
|
106
|
+
k, v = line.split("=", 1)
|
|
107
|
+
items[k] = v
|
|
108
|
+
return items
|
|
109
|
+
except Exception:
|
|
110
|
+
return {}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core.exceptions — Exception classes for bingo-light.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BingoError(Exception):
|
|
11
|
+
"""Base error for bingo-light operations."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitError(BingoError):
|
|
15
|
+
"""A git command failed."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, cmd: List[str], returncode: int, stderr: str):
|
|
18
|
+
self.cmd = cmd
|
|
19
|
+
self.returncode = returncode
|
|
20
|
+
self.stderr = stderr
|
|
21
|
+
super().__init__(
|
|
22
|
+
f"git command failed (exit {returncode}): {' '.join(cmd)}\n{stderr}"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotGitRepoError(BingoError):
|
|
27
|
+
"""Not inside a git repository."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
super().__init__("Not a git repository. Run this inside a git repo.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotInitializedError(BingoError):
|
|
34
|
+
"""bingo-light not initialized in this repo."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
super().__init__(
|
|
38
|
+
"bingo-light not initialized. Run: bingo-light init <upstream-url>"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DirtyTreeError(BingoError):
|
|
43
|
+
"""Working tree has uncommitted changes."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, msg: str = ""):
|
|
46
|
+
super().__init__(
|
|
47
|
+
msg or "Working tree is dirty. Commit or stash your changes first."
|
|
48
|
+
)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core.git — Git subprocess wrapper.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from bingo_core.exceptions import GitError
|
|
13
|
+
from bingo_core.models import PatchInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Git:
|
|
17
|
+
"""Unified git subprocess wrapper. All git calls go through here."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cwd: Optional[str] = None):
|
|
20
|
+
self.cwd = cwd or os.getcwd()
|
|
21
|
+
|
|
22
|
+
def run(self, *args: str, check: bool = True) -> str:
|
|
23
|
+
"""Run a git command and return stdout (stripped).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
*args: git subcommand and arguments (e.g. "rev-parse", "HEAD")
|
|
27
|
+
check: if True, raise GitError on non-zero exit
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
stdout as stripped string
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
GitError: if check=True and command fails
|
|
34
|
+
"""
|
|
35
|
+
cmd = ["git"] + list(args)
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
cmd,
|
|
38
|
+
cwd=self.cwd,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
)
|
|
42
|
+
if check and result.returncode != 0:
|
|
43
|
+
raise GitError(cmd, result.returncode, result.stderr.strip())
|
|
44
|
+
return result.stdout.strip()
|
|
45
|
+
|
|
46
|
+
def run_ok(self, *args: str) -> bool:
|
|
47
|
+
"""Run a git command and return True if it succeeds."""
|
|
48
|
+
try:
|
|
49
|
+
cmd = ["git"] + list(args)
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
cwd=self.cwd,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
return result.returncode == 0
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def run_unchecked(self, *args: str) -> subprocess.CompletedProcess:
|
|
61
|
+
"""Run a git command and return the full CompletedProcess."""
|
|
62
|
+
cmd = ["git"] + list(args)
|
|
63
|
+
return subprocess.run(
|
|
64
|
+
cmd,
|
|
65
|
+
cwd=self.cwd,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def rev_parse(self, ref: str) -> Optional[str]:
|
|
71
|
+
"""Resolve a ref to a commit hash. Returns None if missing."""
|
|
72
|
+
try:
|
|
73
|
+
return self.run("rev-parse", ref)
|
|
74
|
+
except GitError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def rev_parse_short(self, ref: str) -> str:
|
|
78
|
+
"""Resolve a ref to a short commit hash."""
|
|
79
|
+
try:
|
|
80
|
+
return self.run("rev-parse", "--short", ref)
|
|
81
|
+
except GitError:
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
def rev_list_count(self, range_spec: str) -> int:
|
|
85
|
+
"""Count commits in a range. Returns 0 on error."""
|
|
86
|
+
try:
|
|
87
|
+
return int(self.run("rev-list", "--count", range_spec))
|
|
88
|
+
except (GitError, ValueError):
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
def fetch(self, remote: str) -> bool:
|
|
92
|
+
"""Fetch from a remote. Returns True on success."""
|
|
93
|
+
return self.run_ok("fetch", remote)
|
|
94
|
+
|
|
95
|
+
def is_clean(self) -> bool:
|
|
96
|
+
"""Check if working tree is clean (no staged or unstaged changes)."""
|
|
97
|
+
return (
|
|
98
|
+
self.run_ok("diff", "--quiet", "HEAD")
|
|
99
|
+
and self.run_ok("diff", "--cached", "--quiet")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def ls_files_unmerged(self) -> List[str]:
|
|
103
|
+
"""Return list of unmerged file paths (sorted, unique)."""
|
|
104
|
+
try:
|
|
105
|
+
output = self.run("ls-files", "--unmerged", check=False)
|
|
106
|
+
if not output:
|
|
107
|
+
return []
|
|
108
|
+
files = set()
|
|
109
|
+
for line in output.splitlines():
|
|
110
|
+
# format: mode hash stage\tfilename
|
|
111
|
+
parts = line.split("\t", 1)
|
|
112
|
+
if len(parts) == 2:
|
|
113
|
+
files.add(parts[1])
|
|
114
|
+
return sorted(files)
|
|
115
|
+
except Exception:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
def diff_names(self, range_spec: str) -> List[str]:
|
|
119
|
+
"""Return list of changed file names in a range."""
|
|
120
|
+
try:
|
|
121
|
+
output = self.run("diff", "--name-only", range_spec, check=False)
|
|
122
|
+
if not output:
|
|
123
|
+
return []
|
|
124
|
+
return sorted(set(output.splitlines()))
|
|
125
|
+
except Exception:
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
def merge_base(self, ref1: str, ref2: str) -> Optional[str]:
|
|
129
|
+
"""Find merge base of two refs. Returns None if not found."""
|
|
130
|
+
try:
|
|
131
|
+
return self.run("merge-base", ref1, ref2)
|
|
132
|
+
except GitError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def current_branch(self) -> str:
|
|
136
|
+
"""Return name of current branch."""
|
|
137
|
+
try:
|
|
138
|
+
return self.run("branch", "--show-current")
|
|
139
|
+
except GitError:
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
def log_patches(self, base: str, branch: str) -> List[PatchInfo]:
|
|
143
|
+
"""Parse patches from git log in a single pass.
|
|
144
|
+
|
|
145
|
+
Returns list of PatchInfo for commits in base..branch.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
output = self.run(
|
|
149
|
+
"log",
|
|
150
|
+
"--format=PATCH\t%h\t%s",
|
|
151
|
+
"--shortstat",
|
|
152
|
+
"--numstat",
|
|
153
|
+
"--reverse",
|
|
154
|
+
f"{base}..{branch}",
|
|
155
|
+
)
|
|
156
|
+
except GitError:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
patches: List[PatchInfo] = []
|
|
160
|
+
current: Optional[PatchInfo] = None
|
|
161
|
+
|
|
162
|
+
for line in output.splitlines():
|
|
163
|
+
if line.startswith("PATCH\t"):
|
|
164
|
+
if current is not None:
|
|
165
|
+
patches.append(current)
|
|
166
|
+
parts = line.split("\t", 2)
|
|
167
|
+
hash_val = parts[1] if len(parts) > 1 else ""
|
|
168
|
+
subject = parts[2] if len(parts) > 2 else ""
|
|
169
|
+
name = ""
|
|
170
|
+
m = re.match(r"^\[bl\] ([^:]+):", subject)
|
|
171
|
+
if m:
|
|
172
|
+
name = m.group(1)
|
|
173
|
+
current = PatchInfo(
|
|
174
|
+
name=name, hash=hash_val, subject=subject, files=0, stat=""
|
|
175
|
+
)
|
|
176
|
+
elif current is not None:
|
|
177
|
+
# numstat line: add\tdel\tfile (binary shows -\t-\tfile)
|
|
178
|
+
if re.match(r"^(\d+|-)\t(\d+|-)\t", line):
|
|
179
|
+
current.files += 1
|
|
180
|
+
parts = line.split("\t", 2)
|
|
181
|
+
if len(parts) >= 2:
|
|
182
|
+
try:
|
|
183
|
+
current.insertions += int(parts[0])
|
|
184
|
+
current.deletions += int(parts[1])
|
|
185
|
+
except ValueError:
|
|
186
|
+
pass # binary: -\t-\t — counted but no line stats
|
|
187
|
+
# shortstat line
|
|
188
|
+
elif re.match(r"^\s*\d+ file", line):
|
|
189
|
+
current.stat = line.strip()
|
|
190
|
+
|
|
191
|
+
if current is not None:
|
|
192
|
+
patches.append(current)
|
|
193
|
+
|
|
194
|
+
return patches
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core.models — Data classes for bingo-light.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, asdict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PatchInfo:
|
|
12
|
+
"""Information about a single patch in the stack."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
hash: str
|
|
16
|
+
subject: str
|
|
17
|
+
files: int = 0
|
|
18
|
+
stat: str = ""
|
|
19
|
+
insertions: int = 0
|
|
20
|
+
deletions: int = 0
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ConflictInfo:
|
|
28
|
+
"""Information about a conflict in a single file."""
|
|
29
|
+
|
|
30
|
+
file: str
|
|
31
|
+
ours: str = ""
|
|
32
|
+
theirs: str = ""
|
|
33
|
+
conflict_count: int = 0
|
|
34
|
+
merge_hint: str = ""
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return asdict(self)
|