jprotect 0.1.0__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.
jprotect/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Package jProtect."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
jprotect/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from jprotect.cli.main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Package CLI cho jProtect."""
jprotect/cli/main.py ADDED
@@ -0,0 +1,64 @@
1
+ import argparse
2
+
3
+ from jprotect.config.loader import load_from_cli, validate_config
4
+ from jprotect.core.pipeline import run_pipeline
5
+ from jprotect.core.report import print_summary
6
+ from jprotect.errors import JProtectError
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ """Tạo parser cho CLI và đăng ký các tham số của lệnh protect-module.
11
+
12
+ Ví dụ: phân tích ``protect-module --input a --output b --compile``.
13
+ """
14
+ parser = argparse.ArgumentParser(description="jProtect CLI")
15
+ subparsers = parser.add_subparsers(dest="command", required=True)
16
+
17
+ protect_parser = subparsers.add_parser("protect-module", help="Bảo vệ một module Odoo")
18
+ protect_parser.add_argument("--input", required=True, help="Đường dẫn thư mục module Odoo")
19
+ protect_parser.add_argument("--output", required=True, help="Đường dẫn output cho module đã bảo vệ")
20
+ protect_parser.add_argument("--mode", choices=["dist", "inplace"], default="dist")
21
+ protect_parser.add_argument("--dry-run", action="store_true", help="Chỉ quét và báo cáo, không ghi file")
22
+ protect_parser.add_argument(
23
+ "--compile",
24
+ action="store_true",
25
+ help="Compile các file đã tách thành nhị phân (.so/.pyd) bằng Cython",
26
+ )
27
+ protect_parser.add_argument(
28
+ "--include",
29
+ action="append",
30
+ help="Glob include, có thể lặp lại. Mặc định: models/**/*.py",
31
+ )
32
+ protect_parser.add_argument(
33
+ "--exclude",
34
+ action="append",
35
+ help="Glob exclude, có thể lặp lại.",
36
+ )
37
+ return parser
38
+
39
+
40
+ def main() -> int:
41
+ """Điểm vào CLI: đọc tham số, chạy pipeline và in kết quả.
42
+
43
+ Ví dụ: trả về 0 khi thành công, 2 khi gặp JProtectError.
44
+ """
45
+ parser = build_parser()
46
+ args = parser.parse_args()
47
+
48
+ try:
49
+ config = load_from_cli(args)
50
+ validate_config(config)
51
+ report = run_pipeline(config)
52
+ print_summary(report)
53
+ if config.dry_run:
54
+ print("Dry-run mode: no files were written.")
55
+ else:
56
+ print(f"Manifest : {report.get('manifest_path')}")
57
+ return 0
58
+ except JProtectError as exc:
59
+ print(f"Error: {exc}")
60
+ return 2
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Package cấu hình cho jProtect."""
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+
3
+ from jprotect.config.schema import DEFAULT_EXCLUDE_PATTERNS, DEFAULT_INCLUDE_PATTERNS, ProtectConfig
4
+ from jprotect.errors import ConfigError
5
+
6
+
7
+ def _normalize_patterns(patterns: list[str] | None, defaults: list[str]) -> list[str]:
8
+ """Chuẩn hóa danh sách pattern; trả về giá trị mặc định nếu rỗng.
9
+
10
+ Ví dụ: None -> defaults; [' ', ''] -> defaults; [' models/*.py '] -> ['models/*.py'].
11
+ """
12
+ if not patterns:
13
+ return list(defaults)
14
+ normalized = [p.strip() for p in patterns if p and p.strip()]
15
+ if not normalized:
16
+ return list(defaults)
17
+ return normalized
18
+
19
+
20
+ def load_from_cli(args) -> ProtectConfig:
21
+ """Dựng đối tượng args từ argparse thành ProtectConfig.
22
+
23
+ Ví dụ: args(input='a', output='b', mode='dist', compile=True) ->
24
+ ProtectConfig tương ứng với đường dẫn đã resolve.
25
+ """
26
+ include_patterns = _normalize_patterns(args.include, DEFAULT_INCLUDE_PATTERNS)
27
+ exclude_patterns = _normalize_patterns(args.exclude, DEFAULT_EXCLUDE_PATTERNS)
28
+ return ProtectConfig(
29
+ input_path=Path(args.input).resolve(),
30
+ output_path=Path(args.output).resolve(),
31
+ mode=args.mode,
32
+ dry_run=bool(args.dry_run),
33
+ compile=bool(getattr(args, "compile", False)),
34
+ include_patterns=include_patterns,
35
+ exclude_patterns=exclude_patterns,
36
+ )
37
+
38
+
39
+ def validate_config(config: ProtectConfig) -> None:
40
+ """Kiểm tra tính hợp lệ của config trước khi chạy pipeline.
41
+
42
+ Ví dụ: mode lạ -> ConfigError; input không tồn tại -> ConfigError;
43
+ dist nhưng input trùng output -> ConfigError.
44
+ """
45
+ if config.mode not in {"dist", "inplace"}:
46
+ raise ConfigError(f"Unsupported mode: {config.mode}")
47
+
48
+ if not config.input_path.exists() or not config.input_path.is_dir():
49
+ raise ConfigError(f"Input path does not exist or is not a directory: {config.input_path}")
50
+
51
+ if config.mode == "dist" and config.input_path == config.output_path:
52
+ raise ConfigError("Output path must differ from input path when mode is 'dist'.")
53
+
54
+ if config.mode == "inplace" and config.output_path != config.input_path:
55
+ raise ConfigError("Output path must equal input path when mode is 'inplace'.")
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+
5
+ DEFAULT_INCLUDE_PATTERNS = ["models/**/*.py"]
6
+ DEFAULT_EXCLUDE_PATTERNS = [
7
+ "**/__init__.py",
8
+ "**/__manifest__.py",
9
+ "**/migrations/**",
10
+ "**/controllers/**",
11
+ "**/*hook*.py",
12
+ ]
13
+
14
+
15
+ @dataclass
16
+ class ProtectConfig:
17
+ """Cấu hình đầy đủ cho một lần chạy protect-module.
18
+
19
+ Ví dụ: ProtectConfig(input_path=Path('addons/sale_ext'),
20
+ output_path=Path('dist/sale_ext'), mode='dist', compile=True).
21
+ """
22
+
23
+ input_path: Path
24
+ output_path: Path
25
+ mode: str = "dist"
26
+ dry_run: bool = False
27
+ compile: bool = False
28
+ include_patterns: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE_PATTERNS))
29
+ exclude_patterns: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE_PATTERNS))
@@ -0,0 +1 @@
1
+ """Package core (lõi xử lý) cho jProtect."""
@@ -0,0 +1 @@
1
+ """Các backend compile cho jProtect."""
@@ -0,0 +1,106 @@
1
+ """Backend compile Cython (Phase 3).
2
+
3
+ Nhận các file trong package _protected, build thành extension nhị phân (.so/.pyd),
4
+ sau đó xóa file .py và .c trung gian để chỉ giữ lại artifact đã bảo vệ.
5
+
6
+ Lưu ý quan trọng về Odoo: tên extension được đặt theo dạng
7
+ ``<ten_addon>.<duong_dan_tuong_doi>`` và build từ thư mục cha của module. Nhờ vậy
8
+ ``__module__`` của class compile bắt đầu bằng tên addon, giúp Odoo gán model đúng
9
+ addon (Odoo lấy thành phần đầu của ``__module__`` để xác định addon).
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ from jprotect.errors import JProtectError
17
+
18
+ ARTIFACT_SUFFIXES = {".so", ".pyd"}
19
+
20
+
21
+ def _ensure_toolchain() -> None:
22
+ """Kiểm tra Cython/setuptools đã sẵn sàng; nếu thiếu thì báo lỗi rõ ràng."""
23
+ try:
24
+ import Cython.Build # noqa: F401
25
+ import setuptools # noqa: F401
26
+ except ImportError as exc: # pragma: no cover - phụ thuộc môi trường
27
+ raise JProtectError(
28
+ "Thieu Cython/setuptools de compile. Cai bang: pip install \".[compile]\""
29
+ ) from exc
30
+
31
+
32
+ def _module_name(file: Path, base_dir: Path) -> str:
33
+ """Tạo tên module dạng chuỗi có dấu chấm từ đường dẫn tương đối (bỏ .py).
34
+
35
+ Ví dụ: file ``sale_ext/models/_protected/sale_order.py`` với base_dir là cha
36
+ của ``sale_ext`` -> 'sale_ext.models._protected.sale_order'.
37
+ """
38
+ rel = file.relative_to(base_dir).with_suffix("")
39
+ return ".".join(rel.parts)
40
+
41
+
42
+ def _collect_artifacts(protected_files: list[Path]) -> list[Path]:
43
+ """Lấy danh sách artifact .so/.pyd bên cạnh từng file đã compile.
44
+
45
+ Ví dụ: cạnh ``sale_order.py`` tìm được ``sale_order.cp312-win_amd64.pyd``.
46
+ """
47
+ artifacts: list[Path] = []
48
+ for file in protected_files:
49
+ for candidate in file.parent.glob(file.stem + ".*"):
50
+ if candidate.suffix in ARTIFACT_SUFFIXES:
51
+ artifacts.append(candidate)
52
+ return artifacts
53
+
54
+
55
+ def _cleanup_intermediates(protected_files: list[Path], base_dir: Path) -> None:
56
+ """Xóa file .py và .c trung gian, và thư mục build tạm sau khi compile.
57
+
58
+ Ví dụ: sau khi build ``sale_order.py`` -> xóa ``sale_order.py``, ``sale_order.c``
59
+ và thư mục ``build/``, chỉ giữ lại file ``.pyd``/``.so``.
60
+ """
61
+ for file in protected_files:
62
+ c_file = file.with_suffix(".c")
63
+ if c_file.exists():
64
+ c_file.unlink()
65
+ if file.exists():
66
+ file.unlink()
67
+ build_temp = base_dir / "build"
68
+ if build_temp.is_dir():
69
+ shutil.rmtree(build_temp, ignore_errors=True)
70
+
71
+
72
+ def compile_protected_files(files: list[Path], output_root: Path) -> list[Path]:
73
+ """Build các file đã tách thành extension nhị phân (.so/.pyd) bằng Cython.
74
+
75
+ Ví dụ: với ``[.../_protected/sale_order.py]`` và output_root là thư mục module,
76
+ trả về ``[.../_protected/sale_order.cp312-win_amd64.pyd]``.
77
+ """
78
+ targets = [f for f in files if f.name != "__init__.py"]
79
+ if not targets:
80
+ return []
81
+ _ensure_toolchain()
82
+
83
+ from Cython.Build import cythonize
84
+ from setuptools import Extension, setup
85
+
86
+ # Build từ thư mục cha của module để tên extension có tiền tố là tên addon.
87
+ base_dir = output_root.parent
88
+
89
+ cwd = Path.cwd()
90
+ os.chdir(base_dir)
91
+ try:
92
+ extensions = [
93
+ Extension(_module_name(f, base_dir), [f.relative_to(base_dir).as_posix()])
94
+ for f in targets
95
+ ]
96
+ setup(
97
+ name="jprotect_build",
98
+ ext_modules=cythonize(extensions, language_level=3, quiet=True),
99
+ script_args=["build_ext", "--inplace"],
100
+ )
101
+ finally:
102
+ os.chdir(cwd)
103
+
104
+ artifacts = _collect_artifacts(targets)
105
+ _cleanup_intermediates(targets, base_dir)
106
+ return artifacts
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+
3
+ from jprotect.errors import ConfigError
4
+
5
+
6
+ def discover_module_root(input_path: Path) -> Path:
7
+ """Xác minh thư mục đầu vào là module Odoo (có __manifest__.py).
8
+
9
+ Ví dụ: input ``/addons/sale_ext`` có ``__manifest__.py`` -> trả về chính nó;
10
+ nếu thiếu manifest -> ném ConfigError.
11
+ """
12
+ manifest = input_path / "__manifest__.py"
13
+ if not manifest.exists():
14
+ raise ConfigError(f"No __manifest__.py found in module root: {input_path}")
15
+ return input_path
16
+
17
+
18
+ def list_python_files(module_root: Path) -> list[Path]:
19
+ """Quét và trả về danh sách tất cả file .py trong module (đệ quy).
20
+
21
+ Ví dụ: trả về ``[models/__init__.py, models/sale_order.py, ...]``.
22
+ """
23
+ files: list[Path] = []
24
+ for path in module_root.rglob("*.py"):
25
+ if path.is_file():
26
+ files.append(path)
27
+ return sorted(files)
@@ -0,0 +1,37 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def _glob_matches(module_root: Path, patterns: list[str]) -> set[Path]:
5
+ """Trả về tập file dưới ``module_root`` khớp với bất kỳ glob nào.
6
+
7
+ Dùng ``Path.glob`` (hỗ trợ wildcard đệ quy ``**``), nên một pattern duy nhất
8
+ ``models/**/*.py`` khớp cả file ngay trong ``models`` lẫn thư mục con lồng sâu.
9
+ """
10
+ matched: set[Path] = set()
11
+ for pattern in patterns:
12
+ for path in module_root.glob(pattern):
13
+ if path.is_file():
14
+ matched.add(path)
15
+ return matched
16
+
17
+
18
+ def select_targets(
19
+ files: list[Path],
20
+ module_root: Path,
21
+ include_patterns: list[str],
22
+ exclude_patterns: list[str],
23
+ ) -> tuple[list[Path], list[Path]]:
24
+ """Chia ``files`` thành (selected, skipped) theo glob include/exclude.
25
+
26
+ Ví dụ: include=['models/**/*.py'], exclude=['**/__init__.py'] ->
27
+ ``models/sale_order.py`` vào selected, ``models/__init__.py`` vào skipped.
28
+ """
29
+ included = _glob_matches(module_root, include_patterns)
30
+ excluded = _glob_matches(module_root, exclude_patterns)
31
+
32
+ candidate_set = set(files)
33
+ selected = sorted((included & candidate_set) - excluded)
34
+
35
+ selected_lookup = set(selected)
36
+ skipped = [path for path in files if path not in selected_lookup]
37
+ return selected, skipped
@@ -0,0 +1,83 @@
1
+ from pathlib import Path
2
+ from shutil import copytree
3
+
4
+ from jprotect.config.schema import ProtectConfig
5
+ from jprotect.core.compile.cython_backend import compile_protected_files
6
+ from jprotect.core.discovery import discover_module_root, list_python_files
7
+ from jprotect.core.filtering import select_targets
8
+ from jprotect.core.report import build_report, write_manifest_json
9
+ from jprotect.core.safety import assert_safe_output_path
10
+ from jprotect.core.transform.transformer import transform_output_module
11
+ from jprotect.errors import SafetyError
12
+
13
+
14
+ def _prepare_output(config: ProtectConfig, module_root: Path) -> Path:
15
+ """Chuẩn bị thư mục output: giữ nguyên nếu inplace, copy nếu dist.
16
+
17
+ Ví dụ: dist mode sẽ copy toàn bộ module sang ``output_path`` (báo lỗi nếu đã tồn tại).
18
+ """
19
+ if config.mode == "inplace":
20
+ return module_root
21
+
22
+ output_root = config.output_path
23
+ if output_root.exists():
24
+ raise SafetyError(f"Output path already exists: {output_root}")
25
+
26
+ copytree(module_root, output_root)
27
+ return output_root
28
+
29
+
30
+ def run_pipeline(config: ProtectConfig) -> dict:
31
+ """Điều phối toàn bộ pipeline: validate, quét, lọc, transform, compile và report.
32
+
33
+ Ví dụ: dry-run chỉ quét và trả report; chạy thật sẽ tạo output module,
34
+ transform method và (nếu có ``--compile``) build ra .so/.pyd.
35
+ """
36
+ module_root = discover_module_root(config.input_path)
37
+ assert_safe_output_path(module_root, config.output_path, config.mode)
38
+
39
+ files = list_python_files(module_root)
40
+ selected, skipped = select_targets(
41
+ files=files,
42
+ module_root=module_root,
43
+ include_patterns=config.include_patterns,
44
+ exclude_patterns=config.exclude_patterns,
45
+ )
46
+
47
+ if config.dry_run:
48
+ output_root = config.output_path if config.mode == "dist" else module_root
49
+ report = build_report(
50
+ module_root=module_root,
51
+ output_root=output_root,
52
+ selected=selected,
53
+ skipped=skipped,
54
+ dry_run=True,
55
+ mode=config.mode,
56
+ )
57
+ report["manifest_path"] = None
58
+ return report
59
+
60
+ output_root = _prepare_output(config, module_root)
61
+
62
+ # Phase 2: tách thân method sang _protected và viết lại method công khai (delegation).
63
+ transform_result = transform_output_module(output_root, module_root, selected)
64
+
65
+ # Phase 3: compile các file đã tách thành nhị phân (tùy chọn qua --compile).
66
+ compiled_artifacts: list[Path] = []
67
+ if config.compile:
68
+ compiled_artifacts = compile_protected_files(transform_result.protected_files, output_root)
69
+
70
+ report = build_report(
71
+ module_root=module_root,
72
+ output_root=output_root,
73
+ selected=selected,
74
+ skipped=skipped,
75
+ dry_run=False,
76
+ mode=config.mode,
77
+ transformed=transform_result.protected_files,
78
+ compiled=compiled_artifacts,
79
+ compiled_enabled=config.compile,
80
+ )
81
+ manifest_path = write_manifest_json(report, output_root)
82
+ report["manifest_path"] = str(manifest_path)
83
+ return report
@@ -0,0 +1,18 @@
1
+ """Các hàm hỗ trợ hậu xử lý (placeholder cho Phase 3)."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def normalize_artifact_names(artifacts: list[Path]) -> list[Path]:
7
+ """Chuẩn hóa tên file artifact sau khi compile (sẽ làm ở Phase 3).
8
+
9
+ Ví dụ: hiện tại trả về nguyên danh sách, chưa đổi tên.
10
+ """
11
+ return artifacts
12
+
13
+
14
+ def cleanup_temp_files(paths: list[Path]) -> None:
15
+ """Xóa các file trung gian sinh ra trong quá trình build (Phase 3)."""
16
+ for _path in paths:
17
+ # Placeholder: chính sách dọn dẹp sẽ được bổ sung ở Phase 3.
18
+ pass
@@ -0,0 +1,61 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def build_report(
6
+ module_root: Path,
7
+ output_root: Path,
8
+ selected: list[Path],
9
+ skipped: list[Path],
10
+ dry_run: bool,
11
+ mode: str,
12
+ transformed: list[Path] | None = None,
13
+ compiled: list[Path] | None = None,
14
+ compiled_enabled: bool = False,
15
+ ) -> dict:
16
+ """Tạo dict report tổng hợp kết quả quét, lọc, transform và compile.
17
+
18
+ Ví dụ: trả về dict gồm số file selected/skipped/transformed/compiled và
19
+ danh sách đường dẫn tương ứng để ghi ra manifest.
20
+ """
21
+ transformed = transformed or []
22
+ compiled = compiled or []
23
+ return {
24
+ "module_root": str(module_root),
25
+ "output_root": str(output_root),
26
+ "mode": mode,
27
+ "dry_run": dry_run,
28
+ "selected_count": len(selected),
29
+ "skipped_count": len(skipped),
30
+ "transformed_count": len(transformed),
31
+ "compiled_enabled": compiled_enabled,
32
+ "compiled_count": len(compiled),
33
+ "selected_files": [str(p) for p in selected],
34
+ "skipped_files": [str(p) for p in skipped],
35
+ "transformed_files": [str(p) for p in transformed],
36
+ "compiled_artifacts": [str(p) for p in compiled],
37
+ }
38
+
39
+
40
+ def write_manifest_json(report: dict, output_root: Path) -> Path:
41
+ """Ghi report ra file protection-manifest.json trong thư mục output.
42
+
43
+ Ví dụ: tạo ``<output_root>/protection-manifest.json``.
44
+ """
45
+ manifest_path = output_root / "protection-manifest.json"
46
+ manifest_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
47
+ return manifest_path
48
+
49
+
50
+ def print_summary(report: dict) -> None:
51
+ """In tóm tắt kết quả ra console cho người dùng dễ đọc."""
52
+ print("=== jProtect Summary ===")
53
+ print(f"Module root : {report['module_root']}")
54
+ print(f"Output root : {report['output_root']}")
55
+ print(f"Mode : {report['mode']}")
56
+ print(f"Dry run : {report['dry_run']}")
57
+ print(f"Selected : {report['selected_count']}")
58
+ print(f"Skipped : {report['skipped_count']}")
59
+ if not report['dry_run']:
60
+ print(f"Transformed : {report.get('transformed_count', 0)}")
61
+ print(f"Compiled : {report.get('compiled_count', 0)}")
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+
3
+ from jprotect.errors import SafetyError
4
+
5
+
6
+ PROTECTED_NAMES = {"", ".", "..", "/", "\\"}
7
+
8
+
9
+ def assert_no_destructive_target(path: Path) -> None:
10
+ """Chặn các đường dẫn nguy hiểm có thể gây xóa nhầm (root, '.', '..').
11
+
12
+ Ví dụ: path là '/', '.', '..' sẽ ném SafetyError.
13
+ """
14
+ path_str = str(path).strip()
15
+ if path_str in PROTECTED_NAMES:
16
+ raise SafetyError(f"Unsafe path: {path}")
17
+
18
+
19
+ def assert_safe_output_path(input_path: Path, output_path: Path, mode: str) -> None:
20
+ """Đảm bảo output an toàn và không nằm bên trong input khi ở chế độ dist.
21
+
22
+ Ví dụ: input=/addons/sale_ext, output=/addons/sale_ext/dist -> bị chặn
23
+ (output nằm trong input).
24
+ """
25
+ assert_no_destructive_target(output_path)
26
+
27
+ if mode == "dist":
28
+ # Chặn việc tạo output bên trong cây thư mục input.
29
+ input_parts = input_path.resolve().parts
30
+ output_parts = output_path.resolve().parts
31
+ if output_parts[: len(input_parts)] == input_parts:
32
+ raise SafetyError("Output path must not be inside input path in dist mode.")
@@ -0,0 +1 @@
1
+ """Các implementation cho bước transform của jProtect."""