ecdat 0.2.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.
- ecdat/__init__.py +10 -0
- ecdat/__main__.py +5 -0
- ecdat/cli/__init__.py +203 -0
- ecdat/cli/commands/__init__.py +0 -0
- ecdat/cli/commands/about.py +130 -0
- ecdat/cli/commands/demo.py +116 -0
- ecdat/cli/commands/doctor.py +296 -0
- ecdat/cli/commands/help_cmd.py +205 -0
- ecdat/cli/commands/scan.py +228 -0
- ecdat/cli/commands/version_cmd.py +48 -0
- ecdat/cli/parser.py +87 -0
- ecdat/demo_project/auth/login.py +75 -0
- ecdat/demo_project/certs/cert_verify.go +81 -0
- ecdat/demo_project/keyexchange/channel.go +48 -0
- ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
- ecdat/demo_project/payments/payment.py +64 -0
- ecdat/demo_project/quantum/pqc_utils.py +67 -0
- ecdat/demo_project/quantum/slh_signer.py +40 -0
- ecdat/demo_project/tokens/signing.js +54 -0
- ecdat/py.typed +0 -0
- ecdat/services/__init__.py +1 -0
- ecdat/services/crashlog.py +109 -0
- ecdat/services/demo.py +85 -0
- ecdat/services/paths.py +52 -0
- ecdat/services/scanner.py +248 -0
- ecdat/services/viewmodel.py +326 -0
- ecdat/ui/__init__.py +1 -0
- ecdat/ui/art3d.py +136 -0
- ecdat/ui/art_static.py +65 -0
- ecdat/ui/art_text.py +81 -0
- ecdat/ui/banner.py +148 -0
- ecdat/ui/console.py +119 -0
- ecdat/ui/motion.py +64 -0
- ecdat/ui/render.py +486 -0
- ecdat/ui/theme.py +173 -0
- ecdat-0.2.0.dist-info/METADATA +142 -0
- ecdat-0.2.0.dist-info/RECORD +51 -0
- ecdat-0.2.0.dist-info/WHEEL +5 -0
- ecdat-0.2.0.dist-info/entry_points.txt +2 -0
- ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
- ecdat-0.2.0.dist-info/top_level.txt +2 -0
- ecdat_core/__init__.py +6 -0
- ecdat_core/cbom_export.py +287 -0
- ecdat_core/cli.py +202 -0
- ecdat_core/detector.py +273 -0
- ecdat_core/ingestion.py +581 -0
- ecdat_core/models.py +145 -0
- ecdat_core/recommender.py +74 -0
- ecdat_core/risk_engine.py +264 -0
- ecdat_core/signature_loader.py +204 -0
- ecdat_core/signatures.json +692 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Payment processing module — handles card-on-file encryption and
|
|
2
|
+
tokenization for the PCI-DSS-scoped payment pipeline.
|
|
3
|
+
|
|
4
|
+
All card data is encrypted at rest with AES-256-GCM (hardware-accelerated,
|
|
5
|
+
quantum-safe at 256-bit key size) and tokenized via a format-preserving
|
|
6
|
+
encryption scheme before storage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# AES-256 key for card data encryption (32 bytes = 256 bits).
|
|
18
|
+
_CARD_ENCRYPTION_KEY = os.urandom(32)
|
|
19
|
+
|
|
20
|
+
# Nonce size recommended for AES-GCM per NIST SP 800-38D.
|
|
21
|
+
_NONCE_SIZE = 12
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encrypt_card_number(card_number: str) -> dict:
|
|
25
|
+
"""Encrypt a card number with AES-256-GCM and return ciphertext + nonce.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
card_number: The plaintext PAN (primary account number).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A dict with ``nonce`` (hex) and ``ciphertext`` (hex) fields.
|
|
32
|
+
"""
|
|
33
|
+
nonce = os.urandom(_NONCE_SIZE)
|
|
34
|
+
aesgcm = AESGCM(_CARD_ENCRYPTION_KEY)
|
|
35
|
+
ct = aesgcm.encrypt(nonce, card_number.encode("utf-8"), None)
|
|
36
|
+
return {"nonce": nonce.hex(), "ciphertext": ct.hex()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decrypt_card_number(nonce_hex: str, ciphertext_hex: str) -> str:
|
|
40
|
+
"""Decrypt a card number previously encrypted with ``encrypt_card_number``."""
|
|
41
|
+
nonce = bytes.fromhex(nonce_hex)
|
|
42
|
+
ct = bytes.fromhex(ciphertext_hex)
|
|
43
|
+
aesgcm = AESGCM(_CARD_ENCRYPTION_KEY)
|
|
44
|
+
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_encryption_key() -> bytes:
|
|
48
|
+
"""Generate a new AES-256 encryption key.
|
|
49
|
+
|
|
50
|
+
This is used during key rotation — a new 256-bit key is generated,
|
|
51
|
+
old data is re-encrypted, and the old key is retired.
|
|
52
|
+
"""
|
|
53
|
+
return os.urandom(32) # AES-256 (256-bit key)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def encrypt_amount(amount_cents: int, key: bytes) -> bytes:
|
|
57
|
+
"""Encrypt a payment amount for secure transit.
|
|
58
|
+
|
|
59
|
+
Uses the caller-supplied AES key so the caller controls the
|
|
60
|
+
encryption lifecycle.
|
|
61
|
+
"""
|
|
62
|
+
nonce = os.urandom(_NONCE_SIZE)
|
|
63
|
+
aesgcm = AESGCM(key)
|
|
64
|
+
return aesgcm.encrypt(nonce, str(amount_cents).encode(), None)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Post-quantum cryptographic utilities — NIST-standardized algorithms.
|
|
2
|
+
|
|
3
|
+
This module wraps ML-KEM (FIPS 203) and ML-DSA (FIPS 204) for the
|
|
4
|
+
platform's PQC migration path. These are the recommended replacements
|
|
5
|
+
for RSA/ECDH/ECDSA in a post-quantum world.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ML_KEM import MLKEM768
|
|
11
|
+
from ML_DSA import MLDSA65
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── ML-KEM (Key Encapsulation Mechanism) ─────────────────────────────
|
|
15
|
+
|
|
16
|
+
def generate_kem_keypair():
|
|
17
|
+
"""Generate an ML-KEM-768 key pair for post-quantum key exchange.
|
|
18
|
+
|
|
19
|
+
ML-KEM-768 targets NIST security level 3 (~192-bit classical security,
|
|
20
|
+
~128-bit quantum security). This is the recommended parameter set for
|
|
21
|
+
most applications.
|
|
22
|
+
"""
|
|
23
|
+
kem = MLKEM768()
|
|
24
|
+
enc_key, dec_key = kem.generate_key()
|
|
25
|
+
return enc_key, dec_key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def kem_encapsulate(enc_key: bytes) -> tuple[bytes, bytes]:
|
|
29
|
+
"""Encapsulate a shared secret under the recipient's ML-KEM public key.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A (ciphertext, shared_secret) tuple. The ciphertext is sent to
|
|
33
|
+
the recipient; the shared_secret is used for symmetric encryption.
|
|
34
|
+
"""
|
|
35
|
+
kem = MLKEM768()
|
|
36
|
+
ct, ss = kem.encapsulate(enc_key)
|
|
37
|
+
return ct, ss
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def kem_decapsulate(dec_key: bytes, ciphertext: bytes) -> bytes:
|
|
41
|
+
"""Decapsulate a shared secret using the recipient's ML-KEM private key."""
|
|
42
|
+
kem = MLKEM768()
|
|
43
|
+
return kem.decapsulate(dec_key, ciphertext)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── ML-DSA (Digital Signature Algorithm) ─────────────────────────────
|
|
47
|
+
|
|
48
|
+
def generate_signature_keypair():
|
|
49
|
+
"""Generate an ML-DSA-65 key pair for post-quantum signatures.
|
|
50
|
+
|
|
51
|
+
ML-DSA-65 targets NIST security level 3. Signing is fast;
|
|
52
|
+
verification is slower but still practical for most use cases.
|
|
53
|
+
"""
|
|
54
|
+
dsa = MLDSA65()
|
|
55
|
+
return dsa.generate_key()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sign(message: bytes, private_key: bytes) -> bytes:
|
|
59
|
+
"""Sign a message with ML-DSA-65."""
|
|
60
|
+
dsa = MLDSA65()
|
|
61
|
+
return dsa.sign(private_key, message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify(message: bytes, signature: bytes, public_key: bytes) -> bool:
|
|
65
|
+
"""Verify an ML-DSA-65 signature."""
|
|
66
|
+
dsa = MLDSA65()
|
|
67
|
+
return dsa.verify(public_key, message, signature)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SLH-DSA (SPHINCS+) signer — conservative hash-based signatures.
|
|
2
|
+
|
|
3
|
+
SLH-DSA (FIPS 205) is the NIST-standardized stateless hash-based
|
|
4
|
+
signature scheme. It provides a conservative alternative to ML-DSA
|
|
5
|
+
with well-understood security proofs based only on hash function
|
|
6
|
+
assumptions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# SLH-DSA parameter set (SPHINCS+-SHA2-128f)
|
|
15
|
+
_SLHDSA_VARIANT = "SLH-DSA-SHA2-128f"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def slh_dsa_keygen():
|
|
19
|
+
"""Generate an SLH-DSA key pair.
|
|
20
|
+
|
|
21
|
+
SLH-DSA keys are larger than ML-DSA but the security argument
|
|
22
|
+
is simpler: it relies only on the security of the underlying
|
|
23
|
+
hash function.
|
|
24
|
+
"""
|
|
25
|
+
# Placeholder: real implementation would use a PQ library
|
|
26
|
+
private_key = hashlib.sha512(b"slh-dsa-demo-private").digest()
|
|
27
|
+
public_key = hashlib.sha512(b"slh-dsa-demo-public").digest()
|
|
28
|
+
return private_key, public_key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def slh_dsa_sign(message: bytes, private_key: bytes) -> bytes:
|
|
32
|
+
"""Sign a message with SLH-DSA."""
|
|
33
|
+
h = hashlib.sha256(private_key + message).digest()
|
|
34
|
+
return h
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def slh_dsa_verify(message: bytes, signature: bytes, public_key: bytes) -> bool:
|
|
38
|
+
"""Verify an SLH-DSA signature."""
|
|
39
|
+
expected = hashlib.sha256(public_key + message).digest()
|
|
40
|
+
return expected == signature
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token signing service — issues and verifies ECDSA-signed JWTs
|
|
3
|
+
* for the microservice authentication mesh.
|
|
4
|
+
*
|
|
5
|
+
* Uses the P-256 curve (secp256r1) which is standard for JWTs but
|
|
6
|
+
* quantum-vulnerable: Shor's algorithm breaks ECDSA in polynomial time.
|
|
7
|
+
* Migrate to ML-DSA when the ecosystem supports it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
|
|
12
|
+
const ECDSA_CURVE = "prime256v1"; // same as secp256r1 / P-256
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate an ECDSA key pair for token signing.
|
|
16
|
+
* Returns { privateKey, publicKey } in PEM format.
|
|
17
|
+
*/
|
|
18
|
+
function generateKeyPair() {
|
|
19
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", {
|
|
20
|
+
namedCurve: ECDSA_CURVE,
|
|
21
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
22
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
23
|
+
});
|
|
24
|
+
return { privateKey, publicKey };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sign a JSON payload with ECDSA using SHA-256.
|
|
29
|
+
* Returns the base64-encoded signature.
|
|
30
|
+
*/
|
|
31
|
+
function signPayload(payload, privateKeyPem) {
|
|
32
|
+
const data = Buffer.from(JSON.stringify(payload));
|
|
33
|
+
const signature = crypto.sign("sha256", data, {
|
|
34
|
+
key: privateKeyPem,
|
|
35
|
+
dsaEncoding: "ieee-p1363",
|
|
36
|
+
});
|
|
37
|
+
return signature.toString("base64");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify an ECDSA signature against a JSON payload.
|
|
42
|
+
*/
|
|
43
|
+
function verifyPayload(payload, signatureB64, publicKeyPem) {
|
|
44
|
+
const data = Buffer.from(JSON.stringify(payload));
|
|
45
|
+
const sig = Buffer.from(signatureB64, "base64");
|
|
46
|
+
return crypto.verify(
|
|
47
|
+
"sha256",
|
|
48
|
+
data,
|
|
49
|
+
{ key: publicKeyPem, dsaEncoding: "ieee-p1363" },
|
|
50
|
+
sig
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { generateKeyPair, signPayload, verifyPayload };
|
ecdat/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ECDAT app-layer services."""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Crash-log writer for unexpected ECDAT failures.
|
|
2
|
+
|
|
3
|
+
When :func:`ecdat.cli.main` catches an exception it cannot attribute to a
|
|
4
|
+
known user error, it records a crash report under
|
|
5
|
+
:func:`ecdat.services.paths.logs_dir` and points the user at it. Writing a
|
|
6
|
+
crash log must never mask the original failure, so :func:`write_crash_log`
|
|
7
|
+
swallows *every* error and returns ``None`` instead of raising.
|
|
8
|
+
|
|
9
|
+
Only the newest :data:`_MAX_CRASH_LOGS` reports are retained.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import platform
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Sequence
|
|
20
|
+
|
|
21
|
+
from ecdat.services.paths import logs_dir
|
|
22
|
+
|
|
23
|
+
__all__ = ["write_crash_log"]
|
|
24
|
+
|
|
25
|
+
_MAX_CRASH_LOGS = 20
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utc_stamp() -> str:
|
|
29
|
+
"""Return a sortable UTC timestamp (microsecond precision)."""
|
|
30
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_report(exc: BaseException, argv: Sequence[str]) -> str:
|
|
34
|
+
"""Build the human-readable crash report body."""
|
|
35
|
+
from ecdat import __version__
|
|
36
|
+
|
|
37
|
+
tb = "".join(
|
|
38
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
39
|
+
)
|
|
40
|
+
python = sys.version.replace("\n", " ")
|
|
41
|
+
return (
|
|
42
|
+
"ECDAT crash report\n"
|
|
43
|
+
"==================\n"
|
|
44
|
+
f"ecdat: {__version__}\n"
|
|
45
|
+
f"python: {python}\n"
|
|
46
|
+
f"platform: {platform.platform()}\n"
|
|
47
|
+
f"argv: {list(argv)!r}\n"
|
|
48
|
+
f"error: {type(exc).__name__}: {exc}\n"
|
|
49
|
+
"\n"
|
|
50
|
+
"traceback:\n"
|
|
51
|
+
f"{tb}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _trim(directory: Path, keep: int) -> None:
|
|
56
|
+
"""Delete the oldest ``crash-*.log`` files, keeping the newest *keep*.
|
|
57
|
+
|
|
58
|
+
Ordering is by modification time (nanosecond resolution) with the
|
|
59
|
+
timestamp-prefixed name as tiebreaker. Best-effort: unlink failures are
|
|
60
|
+
ignored.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def _sort_key(path: Path):
|
|
64
|
+
try:
|
|
65
|
+
return (path.stat().st_mtime_ns, path.name)
|
|
66
|
+
except OSError:
|
|
67
|
+
return (0, path.name)
|
|
68
|
+
|
|
69
|
+
logs = sorted(directory.glob("crash-*.log"), key=_sort_key)
|
|
70
|
+
stale = logs[:-keep] if keep > 0 else logs
|
|
71
|
+
for old in stale:
|
|
72
|
+
try:
|
|
73
|
+
old.unlink()
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_crash_log(
|
|
79
|
+
exc: BaseException,
|
|
80
|
+
argv: Sequence[str],
|
|
81
|
+
) -> Optional[Path]:
|
|
82
|
+
"""Write a crash report for *exc* and return its path (or ``None``).
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
exc: The unexpected exception to record.
|
|
86
|
+
argv: The command-line arguments the process was launched with.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The path of the newly written log file, or ``None`` if the log could
|
|
90
|
+
not be written (e.g. the data directory is not writable). This
|
|
91
|
+
function never raises.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
directory = logs_dir()
|
|
95
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
stamp = _utc_stamp()
|
|
98
|
+
path = directory / f"crash-{stamp}.log"
|
|
99
|
+
suffix = 0
|
|
100
|
+
while path.exists():
|
|
101
|
+
suffix += 1
|
|
102
|
+
path = directory / f"crash-{stamp}-{suffix}.log"
|
|
103
|
+
|
|
104
|
+
path.write_text(_format_report(exc, argv), encoding="utf-8")
|
|
105
|
+
_trim(directory, _MAX_CRASH_LOGS)
|
|
106
|
+
return path
|
|
107
|
+
except Exception:
|
|
108
|
+
# A crash logger that raises is worse than no crash logger.
|
|
109
|
+
return None
|
ecdat/services/demo.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Access to the bundled sample project shipped inside the ``ecdat`` wheel.
|
|
2
|
+
|
|
3
|
+
The demo project is a deliberately insecure copy of the engine's
|
|
4
|
+
``showcase_repo`` fixture. It lives as package data, so it must be reached
|
|
5
|
+
through :mod:`importlib.resources` — never through ``__file__`` tricks, which
|
|
6
|
+
break inside zip imports and frozen distributions.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
- :func:`demo_source` — the packaged sample as a :class:`Traversable`.
|
|
10
|
+
- :func:`demo_project` — context manager yielding a real, writable copy of
|
|
11
|
+
the sample in a temporary directory.
|
|
12
|
+
- :func:`materialize_demo` — copy the sample to a caller-chosen directory.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.resources
|
|
18
|
+
import shutil
|
|
19
|
+
import tempfile
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Iterator
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
25
|
+
from importlib.resources.abc import Traversable
|
|
26
|
+
|
|
27
|
+
__all__ = ["demo_source", "demo_project", "materialize_demo"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def demo_source() -> "Traversable":
|
|
31
|
+
"""Return the bundled demo project as an importlib resource.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A :class:`Traversable` for the ``ecdat/demo_project`` package-data
|
|
35
|
+
directory. It may or may not be backed by a real filesystem path.
|
|
36
|
+
"""
|
|
37
|
+
return importlib.resources.files("ecdat") / "demo_project"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _copy_tree(source: "Traversable", destination: Path) -> None:
|
|
41
|
+
"""Recursively copy a :class:`Traversable` tree onto *destination*.
|
|
42
|
+
|
|
43
|
+
Walks the traversable directly (``iterdir``) rather than forcing it onto
|
|
44
|
+
disk with ``importlib.resources.as_file`` — this keeps the copy working
|
|
45
|
+
for non-filesystem loaders and avoids any lingering temp extraction.
|
|
46
|
+
"""
|
|
47
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
for child in source.iterdir():
|
|
49
|
+
target = destination / child.name
|
|
50
|
+
if child.is_dir():
|
|
51
|
+
_copy_tree(child, target)
|
|
52
|
+
else:
|
|
53
|
+
target.write_bytes(child.read_bytes())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def materialize_demo(destination: Path) -> Path:
|
|
57
|
+
"""Copy the bundled demo project into *destination* and return it.
|
|
58
|
+
|
|
59
|
+
Any pre-existing *destination* is replaced so the result is always a
|
|
60
|
+
clean, complete copy.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
destination: Directory to (re)create and fill with the sample.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The *destination* path, for convenient chaining.
|
|
67
|
+
"""
|
|
68
|
+
if destination.exists():
|
|
69
|
+
shutil.rmtree(destination)
|
|
70
|
+
_copy_tree(demo_source(), destination)
|
|
71
|
+
return destination
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def demo_project() -> Iterator[Path]:
|
|
76
|
+
"""Yield a writable copy of the bundled demo project.
|
|
77
|
+
|
|
78
|
+
The copy lives in a :class:`~tempfile.TemporaryDirectory` and is deleted
|
|
79
|
+
when the context exits — callers can mutate it freely without touching the
|
|
80
|
+
installed package.
|
|
81
|
+
"""
|
|
82
|
+
with tempfile.TemporaryDirectory(prefix="ecdat-demo-") as tmpdir:
|
|
83
|
+
destination = Path(tmpdir) / "demo_project"
|
|
84
|
+
_copy_tree(demo_source(), destination)
|
|
85
|
+
yield destination
|
ecdat/services/paths.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Filesystem locations for ECDAT user data.
|
|
2
|
+
|
|
3
|
+
Pure, side-effect-free path computations — importing this module creates
|
|
4
|
+
nothing and touches nothing. Directories are created lazily by whoever
|
|
5
|
+
writes (see :mod:`ecdat.services.crashlog`).
|
|
6
|
+
|
|
7
|
+
Resolution order for :func:`home_dir`:
|
|
8
|
+
|
|
9
|
+
1. ``ECDAT_HOME`` (environment override — used by tests and advanced users).
|
|
10
|
+
2. Windows: ``%LOCALAPPDATA%\\ecdat``.
|
|
11
|
+
3. macOS: ``~/Library/Application Support/ecdat``.
|
|
12
|
+
4. Linux/other: ``$XDG_DATA_HOME/ecdat`` or ``~/.local/share/ecdat``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
__all__ = ["home_dir", "logs_dir"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def home_dir() -> Path:
|
|
25
|
+
"""Return the ECDAT user-data root directory (not created).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A :class:`~pathlib.Path` pointing at the app's data root. The path
|
|
29
|
+
may not exist yet — callers are responsible for creating it.
|
|
30
|
+
"""
|
|
31
|
+
override = os.environ.get("ECDAT_HOME")
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser()
|
|
34
|
+
|
|
35
|
+
if sys.platform.startswith("win"):
|
|
36
|
+
local_appdata = os.environ.get("LOCALAPPDATA")
|
|
37
|
+
if local_appdata:
|
|
38
|
+
return Path(local_appdata) / "ecdat"
|
|
39
|
+
return Path.home() / "AppData" / "Local" / "ecdat"
|
|
40
|
+
|
|
41
|
+
if sys.platform == "darwin":
|
|
42
|
+
return Path.home() / "Library" / "Application Support" / "ecdat"
|
|
43
|
+
|
|
44
|
+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
|
45
|
+
if xdg_data_home:
|
|
46
|
+
return Path(xdg_data_home) / "ecdat"
|
|
47
|
+
return Path.home() / ".local" / "share" / "ecdat"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def logs_dir() -> Path:
|
|
51
|
+
"""Return the ECDAT logs directory (``home_dir()/logs``; not created)."""
|
|
52
|
+
return home_dir() / "logs"
|