syncy 0.1.3__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.
- syncy-0.1.3/LICENSE +21 -0
- syncy-0.1.3/MANIFEST.in +18 -0
- syncy-0.1.3/PKG-INFO +22 -0
- syncy-0.1.3/README.md +55 -0
- syncy-0.1.3/requirements.txt +8 -0
- syncy-0.1.3/rules.yaml.example +43 -0
- syncy-0.1.3/setup.cfg +4 -0
- syncy-0.1.3/setup.py +40 -0
- syncy-0.1.3/syncy/__init__.py +9 -0
- syncy-0.1.3/syncy/cli/__init__.py +24 -0
- syncy-0.1.3/syncy/cli/base.py +12 -0
- syncy-0.1.3/syncy/cli/gui.py +18 -0
- syncy-0.1.3/syncy/cli/utils.py +152 -0
- syncy-0.1.3/syncy/cli/validate.py +273 -0
- syncy-0.1.3/syncy/cli/wizard.py +156 -0
- syncy-0.1.3/syncy/config/__init__.py +1 -0
- syncy-0.1.3/syncy/config/rules.py +52 -0
- syncy-0.1.3/syncy/config/runtime.py +111 -0
- syncy-0.1.3/syncy/core/__init__.py +1 -0
- syncy-0.1.3/syncy/core/behaviour.py +67 -0
- syncy-0.1.3/syncy/core/connectors.py +239 -0
- syncy-0.1.3/syncy/core/diff_engine.py +139 -0
- syncy-0.1.3/syncy/core/extractor.py +201 -0
- syncy-0.1.3/syncy/core/report.py +284 -0
- syncy-0.1.3/syncy/gui/__init__.py +13 -0
- syncy-0.1.3/syncy/gui/app.py +514 -0
- syncy-0.1.3/syncy/gui/service.py +142 -0
- syncy-0.1.3/syncy/gui/state.py +35 -0
- syncy-0.1.3/syncy/gui/widgets.py +88 -0
- syncy-0.1.3/syncy.egg-info/SOURCES.txt +28 -0
- syncy-0.1.3/validator.yaml.example +7 -0
syncy-0.1.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 hakambing
|
|
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.
|
syncy-0.1.3/MANIFEST.in
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include LICENSE
|
|
3
|
+
include requirements.txt
|
|
4
|
+
include rules.yaml.example
|
|
5
|
+
include validator.yaml.example
|
|
6
|
+
|
|
7
|
+
prune reports
|
|
8
|
+
prune build
|
|
9
|
+
prune dist
|
|
10
|
+
prune .git
|
|
11
|
+
prune .venv
|
|
12
|
+
prune syncy.egg-info
|
|
13
|
+
prune .pytest_cache
|
|
14
|
+
prune .mypy_cache
|
|
15
|
+
|
|
16
|
+
global-exclude __pycache__ *.py[cod] .DS_Store
|
|
17
|
+
C:\Users\hakam\AppData\Roaming\Python\Python312\Scripts
|
|
18
|
+
|
syncy-0.1.3/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: syncy
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Database migration validation tool (SQL Server ↔ PostgreSQL)
|
|
5
|
+
Author:
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: click>=8.1.0
|
|
12
|
+
Requires-Dist: pyodbc>=5.0.0
|
|
13
|
+
Requires-Dist: psycopg2-binary>=2.9.9
|
|
14
|
+
Requires-Dist: Jinja2>=3.1.2
|
|
15
|
+
Requires-Dist: PyYAML>=6.0.1
|
|
16
|
+
Requires-Dist: sqlparse>=0.4.4
|
|
17
|
+
Requires-Dist: ttkbootstrap>=1.10.1
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
syncy-0.1.3/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Syncy
|
|
2
|
+
=====
|
|
3
|
+
|
|
4
|
+
Syncy is a lightweight database migration validation tool for comparing logic and schema objects between SQL Server and PostgreSQL. It focuses on safe, read-only validation and produces HTML + JSON reports.
|
|
5
|
+
|
|
6
|
+
Features
|
|
7
|
+
- Read-only connectors for MSSQL (pyodbc) and Postgres (psycopg2-binary)
|
|
8
|
+
- Extract views, functions, procedures, triggers metadata/definitions
|
|
9
|
+
- Built-in rule pack (10 cross-engine mismatch checks)
|
|
10
|
+
- Simple behaviour tests on views (sample shape comparison)
|
|
11
|
+
- Timestamped HTML + JSON reports
|
|
12
|
+
|
|
13
|
+
Quick Start
|
|
14
|
+
0. (Recommended) Create and activate a virtual environment
|
|
15
|
+
- Windows (PowerShell):
|
|
16
|
+
python -m venv .venv
|
|
17
|
+
.\.venv\Scripts\Activate.ps1
|
|
18
|
+
- macOS/Linux:
|
|
19
|
+
python3 -m venv .venv
|
|
20
|
+
source .venv/bin/activate
|
|
21
|
+
|
|
22
|
+
1. Install dependencies (Python 3.10+):
|
|
23
|
+
pip install -r requirements.txt
|
|
24
|
+
|
|
25
|
+
2. Provide connection URLs via flags, YAML, or env:
|
|
26
|
+
- Flags: --source, --target
|
|
27
|
+
- YAML: validator.yaml (see example below)
|
|
28
|
+
- Env: SYNCY_SOURCE_URL, SYNCY_TARGET_URL
|
|
29
|
+
|
|
30
|
+
3. Seed sample schemas into your own SQL Server/Postgres (optional):
|
|
31
|
+
python demo/load_samples.py --source "mssql://sa:Your_password123@localhost:1433/master?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes" --target "postgresql://postgres:postgres@localhost:5432/syncy?sslmode=disable"
|
|
32
|
+
|
|
33
|
+
4. Run validation:
|
|
34
|
+
syncy validate --source "mssql://sa:Your_password123@localhost:1433/master?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes" \
|
|
35
|
+
--target "postgresql://postgres:postgres@localhost:5432/syncy?sslmode=disable" \
|
|
36
|
+
--out ./reports/demo/
|
|
37
|
+
|
|
38
|
+
Config Resolution Order
|
|
39
|
+
1) CLI flags > 2) validator.yaml > 3) environment variables.
|
|
40
|
+
|
|
41
|
+
validator.yaml example
|
|
42
|
+
source:
|
|
43
|
+
url: ${SYNCY_SOURCE_URL}
|
|
44
|
+
target:
|
|
45
|
+
url: ${SYNCY_TARGET_URL}
|
|
46
|
+
include_schemas: [public]
|
|
47
|
+
exclude_schemas: []
|
|
48
|
+
|
|
49
|
+
Notes
|
|
50
|
+
- All queries run in safe mode. Obvious write operations are blocked.
|
|
51
|
+
- Reports are written to a timestamped folder under the provided --out or ./reports/.
|
|
52
|
+
- For production environments, you may swap psycopg2-binary for psycopg2 in requirements.
|
|
53
|
+
|
|
54
|
+
License
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Example rule pack for Syncy. You can add/remove rules or edit hints.
|
|
3
|
+
# Each rule is a simple detector for common cross-engine differences.
|
|
4
|
+
|
|
5
|
+
- id: R01
|
|
6
|
+
desc: TOP vs LIMIT
|
|
7
|
+
hint: Replace SQL Server TOP with PostgreSQL LIMIT (or FETCH FIRST)
|
|
8
|
+
|
|
9
|
+
- id: R02
|
|
10
|
+
desc: ISNULL() vs COALESCE()
|
|
11
|
+
hint: Prefer COALESCE for portability; ISNULL is SQL Server-only
|
|
12
|
+
|
|
13
|
+
- id: R03
|
|
14
|
+
desc: IDENTITY vs SEQUENCE
|
|
15
|
+
hint: Use GENERATED AS IDENTITY or a SEQUENCE with nextval() default
|
|
16
|
+
|
|
17
|
+
- id: R04
|
|
18
|
+
desc: BIT vs BOOLEAN
|
|
19
|
+
hint: Map BIT(1) to BOOLEAN; convert 0/1 to FALSE/TRUE
|
|
20
|
+
|
|
21
|
+
- id: R05
|
|
22
|
+
desc: DATETIME vs TIMESTAMP
|
|
23
|
+
hint: Map DATETIME to TIMESTAMP; review precision/timezone
|
|
24
|
+
|
|
25
|
+
- id: R06
|
|
26
|
+
desc: NVARCHAR length mismatch
|
|
27
|
+
hint: Align target VARCHAR length to be >= source
|
|
28
|
+
|
|
29
|
+
- id: R07
|
|
30
|
+
desc: UUID vs UNIQUEIDENTIFIER
|
|
31
|
+
hint: Use PostgreSQL UUID; enable uuid-ossp if generating values
|
|
32
|
+
|
|
33
|
+
- id: R08
|
|
34
|
+
desc: Collation differences
|
|
35
|
+
hint: Remove/adjust COLLATE to target equivalent collation
|
|
36
|
+
|
|
37
|
+
- id: R09
|
|
38
|
+
desc: Trigger timing mismatch
|
|
39
|
+
hint: Convert INSTEAD OF triggers to BEFORE/AFTER with equivalent logic
|
|
40
|
+
|
|
41
|
+
- id: R10
|
|
42
|
+
desc: Function name mismatch
|
|
43
|
+
hint: Translate functions (e.g., LEN -> length/char_length)
|
syncy-0.1.3/setup.cfg
ADDED
syncy-0.1.3/setup.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from setuptools import setup, find_packages
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_version() -> str:
|
|
6
|
+
ns = {}
|
|
7
|
+
with open("syncy/__init__.py", "r", encoding="utf-8") as f:
|
|
8
|
+
exec(f.read(), ns)
|
|
9
|
+
return ns.get("__version__", "0.0.0")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
setup(
|
|
13
|
+
name="syncy",
|
|
14
|
+
version=read_version(),
|
|
15
|
+
description="Database migration validation tool (SQL Server ↔ PostgreSQL)",
|
|
16
|
+
author="",
|
|
17
|
+
packages=find_packages(exclude=("tests", "reports")),
|
|
18
|
+
include_package_data=True,
|
|
19
|
+
install_requires=[
|
|
20
|
+
"click>=8.1.0",
|
|
21
|
+
"pyodbc>=5.0.0",
|
|
22
|
+
"psycopg2-binary>=2.9.9",
|
|
23
|
+
"Jinja2>=3.1.2",
|
|
24
|
+
"PyYAML>=6.0.1",
|
|
25
|
+
"sqlparse>=0.4.4",
|
|
26
|
+
"ttkbootstrap>=1.10.1",
|
|
27
|
+
],
|
|
28
|
+
entry_points={
|
|
29
|
+
"console_scripts": [
|
|
30
|
+
"syncy=syncy.cli:cli",
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
python_requires=">=3.10",
|
|
34
|
+
classifiers=[
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"License :: OSI Approved :: MIT License",
|
|
37
|
+
"Operating System :: OS Independent",
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from ..config.runtime import read_rules_yaml_file as _read_rules_yaml_file
|
|
2
|
+
from ..config.runtime import resolve_rule_pack as _resolve_rule_pack
|
|
3
|
+
from .base import cli
|
|
4
|
+
from .utils import (
|
|
5
|
+
DEFAULT_BEHAVIOUR_LIMIT,
|
|
6
|
+
DEFAULT_BEHAVIOUR_TIMEOUT_S,
|
|
7
|
+
_engine_info,
|
|
8
|
+
_make_output_dir,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Import submodules so commands register on the group
|
|
12
|
+
from . import gui as _gui # noqa: F401
|
|
13
|
+
from . import validate as _validate # noqa: F401
|
|
14
|
+
from . import wizard as _wizard # noqa: F401
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"cli",
|
|
18
|
+
"_resolve_rule_pack",
|
|
19
|
+
"_read_rules_yaml_file",
|
|
20
|
+
"DEFAULT_BEHAVIOUR_LIMIT",
|
|
21
|
+
"DEFAULT_BEHAVIOUR_TIMEOUT_S",
|
|
22
|
+
"_engine_info",
|
|
23
|
+
"_make_output_dir",
|
|
24
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .base import cli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@cli.command("gui")
|
|
9
|
+
def gui() -> None:
|
|
10
|
+
"""Launch the desktop GUI wrapper."""
|
|
11
|
+
try:
|
|
12
|
+
from ..gui.app import main as gui_main
|
|
13
|
+
except Exception as exc: # pragma: no cover - GUI-only failure
|
|
14
|
+
raise click.ClickException(f"GUI not available: {exc}")
|
|
15
|
+
gui_main()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["gui"]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
from urllib.parse import quote_plus
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_REPORTS_DIR = "reports"
|
|
12
|
+
LOCAL_TZ = timezone(timedelta(hours=8))
|
|
13
|
+
DEFAULT_BEHAVIOUR_LIMIT = 5
|
|
14
|
+
DEFAULT_BEHAVIOUR_TIMEOUT_S = 30
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _timestamp() -> str:
|
|
18
|
+
return datetime.now(LOCAL_TZ).strftime("%Y%m%d_%H%M%S%z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _prompt_connection_engine(kind: str, default_scheme: str) -> str:
|
|
22
|
+
choice = click.prompt(
|
|
23
|
+
f"Choose {kind} engine",
|
|
24
|
+
type=click.Choice(["mssql", "postgresql"], case_sensitive=False),
|
|
25
|
+
default=default_scheme,
|
|
26
|
+
)
|
|
27
|
+
return choice.lower()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _prompt_optional_params(scheme: str) -> Dict[str, str]:
|
|
31
|
+
params: Dict[str, str] = {}
|
|
32
|
+
if scheme == "mssql":
|
|
33
|
+
driver = click.prompt(
|
|
34
|
+
"ODBC driver",
|
|
35
|
+
default="ODBC Driver 18 for SQL Server",
|
|
36
|
+
)
|
|
37
|
+
encrypt = "yes" if click.confirm("Encrypt connection?", default=True) else "no"
|
|
38
|
+
trust = (
|
|
39
|
+
"yes"
|
|
40
|
+
if click.confirm("Trust server certificate?", default=True)
|
|
41
|
+
else "no"
|
|
42
|
+
)
|
|
43
|
+
params["driver"] = driver
|
|
44
|
+
params["Encrypt"] = encrypt
|
|
45
|
+
params["TrustServerCertificate"] = trust
|
|
46
|
+
else:
|
|
47
|
+
sslmode = click.prompt(
|
|
48
|
+
"SSL mode (leave blank to skip)",
|
|
49
|
+
default="",
|
|
50
|
+
show_default=False,
|
|
51
|
+
).strip()
|
|
52
|
+
if sslmode:
|
|
53
|
+
params["sslmode"] = sslmode
|
|
54
|
+
|
|
55
|
+
extra = click.prompt(
|
|
56
|
+
"Additional query params (key=value&key2=value2, blank to skip)",
|
|
57
|
+
default="",
|
|
58
|
+
show_default=False,
|
|
59
|
+
).strip()
|
|
60
|
+
if extra:
|
|
61
|
+
for chunk in extra.split("&"):
|
|
62
|
+
if "=" not in chunk:
|
|
63
|
+
continue
|
|
64
|
+
key, value = chunk.split("=", 1)
|
|
65
|
+
key = key.strip()
|
|
66
|
+
value = value.strip()
|
|
67
|
+
if key:
|
|
68
|
+
params[key] = value
|
|
69
|
+
return params
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_connection_url(kind: str, default_scheme: str) -> str:
|
|
73
|
+
while True:
|
|
74
|
+
scheme = _prompt_connection_engine(kind, default_scheme)
|
|
75
|
+
default_port = 1433 if scheme == "mssql" else 5432
|
|
76
|
+
default_db = "master" if scheme == "mssql" else "postgres"
|
|
77
|
+
host = click.prompt("Host", default="localhost").strip()
|
|
78
|
+
port = click.prompt("Port", default=default_port, type=int)
|
|
79
|
+
database = click.prompt("Database name", default=default_db).strip()
|
|
80
|
+
username = click.prompt(
|
|
81
|
+
"Username (leave blank for none)",
|
|
82
|
+
default="",
|
|
83
|
+
show_default=False,
|
|
84
|
+
).strip()
|
|
85
|
+
password = click.prompt(
|
|
86
|
+
"Password (leave blank for none)",
|
|
87
|
+
default="",
|
|
88
|
+
show_default=False,
|
|
89
|
+
hide_input=True,
|
|
90
|
+
)
|
|
91
|
+
params = _prompt_optional_params(scheme)
|
|
92
|
+
|
|
93
|
+
auth = ""
|
|
94
|
+
if username:
|
|
95
|
+
auth = quote_plus(username)
|
|
96
|
+
if password:
|
|
97
|
+
auth = f"{auth}:{quote_plus(password)}"
|
|
98
|
+
auth = f"{auth}@"
|
|
99
|
+
elif password:
|
|
100
|
+
auth = f":{quote_plus(password)}@"
|
|
101
|
+
|
|
102
|
+
host_part = host
|
|
103
|
+
if port:
|
|
104
|
+
host_part = f"{host}:{port}"
|
|
105
|
+
|
|
106
|
+
path = f"/{database}" if database else ""
|
|
107
|
+
query = "&".join(
|
|
108
|
+
f"{quote_plus(str(k))}={quote_plus(str(v))}" for k, v in params.items()
|
|
109
|
+
)
|
|
110
|
+
url = f"{scheme}://{auth}{host_part}{path}"
|
|
111
|
+
if query:
|
|
112
|
+
url = f"{url}?{query}"
|
|
113
|
+
|
|
114
|
+
click.echo(f"\nConstructed {kind} URL:\n{url}\n")
|
|
115
|
+
if click.confirm("Use this URL?", default=True):
|
|
116
|
+
return url
|
|
117
|
+
click.echo("Let's try building it again.\n")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _make_output_dir(base_out: Optional[str]) -> Path:
|
|
121
|
+
base = Path(base_out) if base_out else Path(DEFAULT_REPORTS_DIR)
|
|
122
|
+
out_dir = base / _timestamp()
|
|
123
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
return out_dir
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _engine_info(conn) -> Dict[str, str]:
|
|
128
|
+
"""Return a simple engine and version string for overview."""
|
|
129
|
+
try:
|
|
130
|
+
from ..core.connectors import MSSQLConnector, PostgresConnector
|
|
131
|
+
|
|
132
|
+
if isinstance(conn, MSSQLConnector):
|
|
133
|
+
row = conn.fetchone("SELECT @@VERSION")
|
|
134
|
+
return {"engine": "SQL Server", "version": (row[0] if row else "")}
|
|
135
|
+
if isinstance(conn, PostgresConnector):
|
|
136
|
+
row = conn.fetchone("SELECT version()")
|
|
137
|
+
return {"engine": "PostgreSQL", "version": (row[0] if row else "")}
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
name = conn.__class__.__name__.replace("Connector", "")
|
|
141
|
+
return {"engine": name, "version": ""}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"DEFAULT_BEHAVIOUR_LIMIT",
|
|
146
|
+
"DEFAULT_BEHAVIOUR_TIMEOUT_S",
|
|
147
|
+
"DEFAULT_REPORTS_DIR",
|
|
148
|
+
"LOCAL_TZ",
|
|
149
|
+
"_build_connection_url",
|
|
150
|
+
"_engine_info",
|
|
151
|
+
"_make_output_dir",
|
|
152
|
+
]
|