pymdkit 1.6.2__tar.gz → 1.6.4__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.
Files changed (61) hide show
  1. {pymdkit-1.6.2 → pymdkit-1.6.4}/PKG-INFO +32 -4
  2. {pymdkit-1.6.2 → pymdkit-1.6.4}/README.md +31 -3
  3. {pymdkit-1.6.2 → pymdkit-1.6.4}/pyproject.toml +1 -1
  4. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/__init__.py +1 -1
  5. pymdkit-1.6.4/src/pymdkit/_config.py +89 -0
  6. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_cifio.py +19 -0
  7. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_symmetry.py +1 -3
  8. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_vaspset.py +3 -5
  9. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/compute_rmsd.py +3 -4
  10. pymdkit-1.6.4/src/pymdkit/commands/config.py +45 -0
  11. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/convert.py +2 -5
  12. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/ewald.py +6 -3
  13. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/realloc_occ.py +2 -4
  14. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/substitute.py +1 -3
  15. pymdkit-1.6.4/src/pymdkit/pymdkit_main.py +253 -0
  16. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/PKG-INFO +32 -4
  17. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/SOURCES.txt +3 -0
  18. pymdkit-1.6.4/tests/test_config_logging.py +97 -0
  19. {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_lattice_symmetry.py +34 -0
  20. {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_realloc_occ.py +2 -2
  21. pymdkit-1.6.2/src/pymdkit/pymdkit_main.py +0 -123
  22. {pymdkit-1.6.2 → pymdkit-1.6.4}/LICENSE +0 -0
  23. {pymdkit-1.6.2 → pymdkit-1.6.4}/setup.cfg +0 -0
  24. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/__init__.py +0 -0
  25. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_extxyz.py +0 -0
  26. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_fileio.py +0 -0
  27. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_geometry.py +0 -0
  28. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_gpumd.py +0 -0
  29. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_lattice.py +0 -0
  30. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_oxidation.py +0 -0
  31. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_precision.py +0 -0
  32. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_reduction.py +0 -0
  33. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_supercell.py +0 -0
  34. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/_vasp_output.py +0 -0
  35. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/add_config_type.py +0 -0
  36. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/chemsys_entry.py +0 -0
  37. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/compute_ehull.py +0 -0
  38. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gather_fs.py +0 -0
  39. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_group.py +0 -0
  40. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_msd.py +0 -0
  41. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_relax.py +0 -0
  42. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_thermo.py +0 -0
  43. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/gpumd_trjcat.py +0 -0
  44. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/nep_rmse.py +0 -0
  45. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/pca.py +0 -0
  46. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/perturb.py +0 -0
  47. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/submit_vasp.py +0 -0
  48. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/supercell.py +0 -0
  49. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/trj_extract.py +0 -0
  50. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/trj_sparse.py +0 -0
  51. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/umap.py +0 -0
  52. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp2xyz.py +0 -0
  53. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp_fe.py +0 -0
  54. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp_relax.py +0 -0
  55. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit/commands/vasp_static.py +0 -0
  56. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/dependency_links.txt +0 -0
  57. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/entry_points.txt +0 -0
  58. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/requires.txt +0 -0
  59. {pymdkit-1.6.2 → pymdkit-1.6.4}/src/pymdkit.egg-info/top_level.txt +0 -0
  60. {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_convert.py +0 -0
  61. {pymdkit-1.6.2 → pymdkit-1.6.4}/tests/test_vaspset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.6.2
3
+ Version: 1.6.4
4
4
  Summary: A unified command-line toolkit for atomistic / MD structure workflows.
5
5
  Author-email: Yueda Wang <ydwang0608@ustc.edu.cn>
6
6
  License-Expression: GPL-3.0-or-later
@@ -63,6 +63,27 @@ pmk -help # lists every command
63
63
  pmk <command> -help # shows that command's flags
64
64
  ```
65
65
 
66
+ ## Global configuration and logs
67
+
68
+ On first use, PMK creates `.pmkrc.yaml` in the active Python environment root.
69
+ For example, an executable at `~/anaconda3/envs/pymdkit/bin/pmk` uses
70
+ `~/anaconda3/envs/pymdkit/.pmkrc.yaml`. View the active file and settings with:
71
+
72
+ ```bash
73
+ pmk config
74
+ pmk config silence -on
75
+ pmk config silence -off
76
+ ```
77
+
78
+ With silence enabled, successful commands do not print to the terminal. Errors
79
+ that prevent an operation are still shown. Command details are never discarded:
80
+ every `pmk <command> ...` invocation appends its exact command, timestamp, exit
81
+ status, and screen output to `<command>.txt` in the directory where PMK was
82
+ invoked. Repeated runs remain in chronological order in the same file. This log
83
+ always stays in the invocation directory even when `-of` selects an output
84
+ folder. Existing command reports that use the same filename, such as `rmsd.txt`
85
+ and `ehull.txt`, are preserved as the report section of that run.
86
+
66
87
  ## Commands
67
88
 
68
89
  Commands that transform structures accept either a single file (`-i`/`-o`) or a
@@ -71,6 +92,7 @@ directory for job sub-folders automatically.
71
92
 
72
93
  | Command | What it does |
73
94
  |---|---|
95
+ | `config` | View or edit global PMK settings in `.pmkrc.yaml` |
74
96
  | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
75
97
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
76
98
  | `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
@@ -265,7 +287,7 @@ first, until every multiplicity times occupancy times supercell determinant is
265
287
  an integer. Equal-volume choices favor the most isotropic resulting cell.
266
288
  All selections, site atom counts, oxidation states, charges, failures, the
267
289
  allowed occupancy grid, and the final summary are recorded in
268
- `OUTPUT/realloc-occ.txt` for both single-file and folder modes.
290
+ `realloc-occ.txt` in the invocation directory for both single-file and folder modes.
269
291
 
270
292
  `gather-fs` recursively scans all job folders below the current directory. For
271
293
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -336,8 +358,14 @@ CIF input with pymatgen rather than ASE. ASE interprets
336
358
  `_space_group_crystal_system` as a setting hint only for rhombohedral groups
337
359
  and emits a potentially misleading warning for other crystal systems. Using
338
360
  pymatgen avoids that ambiguity while PMK continues to use ASE for non-CIF
339
- formats. A partially occupied CIF must first be ordered with `convert -d2o`
340
- before it can be represented as an explicit atom list.
361
+ formats. Pymatgen intentionally snaps fractional coordinates within its
362
+ default `1e-4` tolerance to ideal values before applying symmetry. PMK keeps
363
+ that corrective behavior but hides only its corresponding finite-precision
364
+ notice; composition, occupancy, malformed-CIF, and all other warnings remain
365
+ visible. See the
366
+ [pymatgen CifParser documentation](https://pymatgen.org/pymatgen.io.html#pymatgen.io.cif.CifParser).
367
+ A partially occupied CIF must first be ordered with `convert -d2o` before it
368
+ can be represented as an explicit atom list.
341
369
 
342
370
  CIF `_atom_type_symbol` and `_atom_site_label` values use per-element
343
371
  inequivalent-site identifiers such as `Li1`, `Y1`, `Cl1`, and `Cl2`.
@@ -36,6 +36,27 @@ pmk -help # lists every command
36
36
  pmk <command> -help # shows that command's flags
37
37
  ```
38
38
 
39
+ ## Global configuration and logs
40
+
41
+ On first use, PMK creates `.pmkrc.yaml` in the active Python environment root.
42
+ For example, an executable at `~/anaconda3/envs/pymdkit/bin/pmk` uses
43
+ `~/anaconda3/envs/pymdkit/.pmkrc.yaml`. View the active file and settings with:
44
+
45
+ ```bash
46
+ pmk config
47
+ pmk config silence -on
48
+ pmk config silence -off
49
+ ```
50
+
51
+ With silence enabled, successful commands do not print to the terminal. Errors
52
+ that prevent an operation are still shown. Command details are never discarded:
53
+ every `pmk <command> ...` invocation appends its exact command, timestamp, exit
54
+ status, and screen output to `<command>.txt` in the directory where PMK was
55
+ invoked. Repeated runs remain in chronological order in the same file. This log
56
+ always stays in the invocation directory even when `-of` selects an output
57
+ folder. Existing command reports that use the same filename, such as `rmsd.txt`
58
+ and `ehull.txt`, are preserved as the report section of that run.
59
+
39
60
  ## Commands
40
61
 
41
62
  Commands that transform structures accept either a single file (`-i`/`-o`) or a
@@ -44,6 +65,7 @@ directory for job sub-folders automatically.
44
65
 
45
66
  | Command | What it does |
46
67
  |---|---|
68
+ | `config` | View or edit global PMK settings in `.pmkrc.yaml` |
47
69
  | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
48
70
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
49
71
  | `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
@@ -238,7 +260,7 @@ first, until every multiplicity times occupancy times supercell determinant is
238
260
  an integer. Equal-volume choices favor the most isotropic resulting cell.
239
261
  All selections, site atom counts, oxidation states, charges, failures, the
240
262
  allowed occupancy grid, and the final summary are recorded in
241
- `OUTPUT/realloc-occ.txt` for both single-file and folder modes.
263
+ `realloc-occ.txt` in the invocation directory for both single-file and folder modes.
242
264
 
243
265
  `gather-fs` recursively scans all job folders below the current directory. For
244
266
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -309,8 +331,14 @@ CIF input with pymatgen rather than ASE. ASE interprets
309
331
  `_space_group_crystal_system` as a setting hint only for rhombohedral groups
310
332
  and emits a potentially misleading warning for other crystal systems. Using
311
333
  pymatgen avoids that ambiguity while PMK continues to use ASE for non-CIF
312
- formats. A partially occupied CIF must first be ordered with `convert -d2o`
313
- before it can be represented as an explicit atom list.
334
+ formats. Pymatgen intentionally snaps fractional coordinates within its
335
+ default `1e-4` tolerance to ideal values before applying symmetry. PMK keeps
336
+ that corrective behavior but hides only its corresponding finite-precision
337
+ notice; composition, occupancy, malformed-CIF, and all other warnings remain
338
+ visible. See the
339
+ [pymatgen CifParser documentation](https://pymatgen.org/pymatgen.io.html#pymatgen.io.cif.CifParser).
340
+ A partially occupied CIF must first be ordered with `convert -d2o` before it
341
+ can be represented as an explicit atom list.
314
342
 
315
343
  CIF `_atom_type_symbol` and `_atom_site_label` values use per-element
316
344
  inequivalent-site identifiers such as `Li1`, `Y1`, `Cl1`, and `Cl2`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pymdkit"
7
- version = "1.6.2"
7
+ version = "1.6.4"
8
8
  description = "A unified command-line toolkit for atomistic / MD structure workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """pymdkit -- a unified CLI for atomistic / MD structure workflows."""
2
2
 
3
- __version__ = "1.6.2"
3
+ __version__ = "1.6.4"
@@ -0,0 +1,89 @@
1
+ """Global PMK configuration stored in the active Python environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ DEFAULT_CONFIG: Dict[str, Any] = {"silence": False}
13
+
14
+
15
+ def config_path() -> Path:
16
+ """Return the PMK configuration file for the active Python environment."""
17
+ override = os.environ.get("PMK_CONFIG_FILE")
18
+ if override:
19
+ return Path(override).expanduser().resolve()
20
+ return Path(sys.prefix).resolve() / ".pmkrc.yaml"
21
+
22
+
23
+ def _parse_scalar(raw: str) -> Any:
24
+ value = raw.strip()
25
+ lowered = value.lower()
26
+ if lowered in {"true", "yes", "on"}:
27
+ return True
28
+ if lowered in {"false", "no", "off"}:
29
+ return False
30
+ if lowered in {"null", "none", "~"}:
31
+ return None
32
+ try:
33
+ return ast.literal_eval(value)
34
+ except (SyntaxError, ValueError):
35
+ return value
36
+
37
+
38
+ def _read_flat_yaml(path: Path) -> Dict[str, Any]:
39
+ settings: Dict[str, Any] = {}
40
+ for line_number, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
41
+ stripped = line.strip()
42
+ if not stripped or stripped.startswith("#"):
43
+ continue
44
+ if ":" not in line:
45
+ raise ValueError(f"invalid YAML at {path}:{line_number}: expected 'name: value'")
46
+ key, raw = line.split(":", 1)
47
+ key = key.strip()
48
+ if not key or any(char.isspace() for char in key):
49
+ raise ValueError(f"invalid setting name at {path}:{line_number}")
50
+ settings[key] = _parse_scalar(raw)
51
+ return settings
52
+
53
+
54
+ def _format_scalar(value: Any) -> str:
55
+ if isinstance(value, bool):
56
+ return "true" if value else "false"
57
+ if value is None:
58
+ return "null"
59
+ if isinstance(value, (int, float)):
60
+ return str(value)
61
+ return json.dumps(str(value), ensure_ascii=True)
62
+
63
+
64
+ def save_config(settings: Dict[str, Any], path: Optional[Path] = None) -> Path:
65
+ destination = config_path() if path is None else Path(path)
66
+ destination.parent.mkdir(parents=True, exist_ok=True)
67
+ ordered_keys = [key for key in DEFAULT_CONFIG if key in settings]
68
+ ordered_keys.extend(sorted(key for key in settings if key not in DEFAULT_CONFIG))
69
+ text = "# Global PMK settings. Edit with `pmk config` or a text editor.\n"
70
+ text += "".join(f"{key}: {_format_scalar(settings[key])}\n" for key in ordered_keys)
71
+ temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
72
+ temporary.write_text(text, encoding="utf-8")
73
+ temporary.replace(destination)
74
+ return destination
75
+
76
+
77
+ def load_config(*, create: bool = True) -> Dict[str, Any]:
78
+ path = config_path()
79
+ if path.exists():
80
+ settings = _read_flat_yaml(path)
81
+ else:
82
+ settings = {}
83
+ merged = dict(DEFAULT_CONFIG)
84
+ merged.update(settings)
85
+ if not isinstance(merged["silence"], bool):
86
+ raise ValueError(f"setting 'silence' in {path} must be true or false")
87
+ if create and not path.exists():
88
+ save_config(merged, path)
89
+ return merged
@@ -41,12 +41,31 @@ CIF_FLOAT_TAGS = {
41
41
  _CIF_DECIMAL_RE = re.compile(
42
42
  r"[-+]?(?:(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?|\d+[eE][-+]?\d+)"
43
43
  )
44
+ _BENIGN_FRACTIONAL_ROUNDING_WARNING = (
45
+ r"Issues encountered while parsing CIF:\s*\d+ fractional coordinates? "
46
+ r"rounded to ideal values to avoid issues with finite precision\.\s*$"
47
+ )
44
48
 
45
49
 
46
50
  def is_cif(path):
47
51
  return Path(path).suffix.lower() == ".cif"
48
52
 
49
53
 
54
+ def read_structure(path):
55
+ """Read with pymatgen, hiding only its benign CIF idealization notice."""
56
+ from pymatgen.core import Structure
57
+
58
+ if not is_cif(path):
59
+ return Structure.from_file(str(path))
60
+ with warnings.catch_warnings():
61
+ warnings.filterwarnings(
62
+ "ignore",
63
+ message=_BENIGN_FRACTIONAL_ROUNDING_WARNING,
64
+ category=UserWarning,
65
+ )
66
+ return Structure.from_file(str(path))
67
+
68
+
50
69
  def as_pymatgen_structure(structure):
51
70
  """Return a periodic pymatgen Structure from pymatgen or ASE input."""
52
71
  from pymatgen.core import Structure
@@ -36,11 +36,9 @@ DEFAULT_OXIDATION_STATES = {
36
36
 
37
37
 
38
38
  def load_structure(path):
39
- from pymatgen.core import Structure
40
-
41
39
  path = Path(path)
42
40
  try:
43
- return Structure.from_file(str(path))
41
+ return _cifio.read_structure(path)
44
42
  except Exception as pymatgen_error:
45
43
  if _cifio.is_cif(path):
46
44
  raise ValueError(
@@ -17,12 +17,12 @@ from __future__ import annotations
17
17
 
18
18
  import argparse
19
19
  import ast
20
- import warnings
21
20
  from pathlib import Path
22
21
 
23
22
  try:
24
- from . import _lattice, _precision, _supercell
23
+ from . import _cifio, _lattice, _precision, _supercell
25
24
  except ImportError:
25
+ import _cifio
26
26
  import _lattice
27
27
  import _precision
28
28
  import _supercell
@@ -216,8 +216,6 @@ def _run_trajectory(args, set_cls, settings):
216
216
  def run_vasp_set(args, set_cls, default_settings):
217
217
  """Write *set_cls* input files for one structure (-i), a folder (-if), or a
218
218
  trajectory (-it). Defaults can be overridden via ``-custom-setting FILE``."""
219
- warnings.filterwarnings("ignore", category=UserWarning, module="pymatgen")
220
- from pymatgen.core import Structure
221
219
 
222
220
  settings = dict(default_settings)
223
221
  if args.custom_setting:
@@ -244,7 +242,7 @@ def run_vasp_set(args, set_cls, default_settings):
244
242
  written = 0
245
243
  for struct_file, out_dir in jobs:
246
244
  try:
247
- structure = Structure.from_file(str(struct_file))
245
+ structure = _cifio.read_structure(struct_file)
248
246
  except Exception as e: # noqa: BLE001 - report and continue in batch
249
247
  print(f"{struct_file}: cannot read ({e}) -> skipping")
250
248
  continue
@@ -11,8 +11,9 @@ from itertools import combinations
11
11
  from pathlib import Path
12
12
 
13
13
  try:
14
- from . import _fileio
14
+ from . import _cifio, _fileio
15
15
  except ImportError:
16
+ import _cifio
16
17
  import _fileio
17
18
 
18
19
  COMMAND = "rmsd"
@@ -49,11 +50,9 @@ def create_matcher():
49
50
 
50
51
 
51
52
  def load_structure(path):
52
- from pymatgen.core import Structure
53
-
54
53
  path = Path(path)
55
54
  try:
56
- return Structure.from_file(str(path))
55
+ return _cifio.read_structure(path)
57
56
  except Exception as pymatgen_error:
58
57
  if path.suffix.lower() == ".cif":
59
58
  print(f" [warn] pymatgen could not read CIF {path}: {pymatgen_error}")
@@ -0,0 +1,45 @@
1
+ """View or edit global PMK settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pymdkit._config import config_path, load_config, save_config
6
+
7
+ COMMAND = "config"
8
+ HELP = "View or edit global PMK settings."
9
+
10
+
11
+ def add_arguments(parser):
12
+ parser.add_argument("setting", nargs="?", choices=("silence",),
13
+ help="Setting to update; omit it to show all settings.")
14
+ switch = parser.add_mutually_exclusive_group()
15
+ switch.add_argument("-on", dest="value", action="store_const", const=True,
16
+ help="Enable the selected setting.")
17
+ switch.add_argument("-off", dest="value", action="store_const", const=False,
18
+ help="Disable the selected setting.")
19
+
20
+
21
+ def run(args):
22
+ settings = load_config()
23
+ value = getattr(args, "value", None)
24
+ if args.setting is None:
25
+ if value is not None:
26
+ print("Error: -on or -off requires a setting name.")
27
+ return 2
28
+ print(f"Configuration file: {config_path()}")
29
+ for name in sorted(settings):
30
+ if settings[name] is True:
31
+ shown = "on"
32
+ elif settings[name] is False:
33
+ shown = "off"
34
+ else:
35
+ shown = settings[name]
36
+ print(f"{name}: {shown}")
37
+ return 0
38
+ if value is None:
39
+ print(f"Error: pmk config {args.setting} requires -on or -off.")
40
+ return 2
41
+ settings[args.setting] = value
42
+ path = save_config(settings)
43
+ print(f"{args.setting}: {'on' if value else 'off'}")
44
+ print(f"Updated {path}")
45
+ return 0
@@ -23,10 +23,9 @@ HELP = "Convert structures, refine CIF symmetry, or order disorder."
23
23
 
24
24
  def _read_cif(path):
25
25
  """Read CIF with pymatgen, avoiding ASE's setting-name ambiguity."""
26
- from pymatgen.core import Structure
27
26
  from pymatgen.io.ase import AseAtomsAdaptor
28
27
 
29
- structure = Structure.from_file(str(path))
28
+ structure = _cifio.read_structure(path)
30
29
  if not structure.is_ordered:
31
30
  raise ValueError(
32
31
  "partially occupied CIF cannot be converted to an explicit atom "
@@ -149,9 +148,7 @@ def _write(
149
148
  ) from pmg_error
150
149
 
151
150
  def _read_structure(path):
152
- from pymatgen.core import Structure
153
-
154
- return Structure.from_file(str(path))
151
+ return _cifio.read_structure(path)
155
152
 
156
153
 
157
154
  def _write_structure(
@@ -14,14 +14,17 @@ from __future__ import annotations
14
14
  import argparse
15
15
  from pathlib import Path
16
16
 
17
+ try:
18
+ from . import _cifio
19
+ except ImportError:
20
+ import _cifio
21
+
17
22
  COMMAND = "ewald"
18
23
  HELP = "Compute CIF electrostatic energy using pymatgen EwaldSummation."
19
24
 
20
25
 
21
26
  def read_charged_structure(path):
22
- from pymatgen.core import Structure
23
-
24
- structure = Structure.from_file(str(path))
27
+ structure = _cifio.read_structure(path)
25
28
  missing = []
26
29
  for site in structure:
27
30
  for specie in site.species:
@@ -580,9 +580,7 @@ def _write_candidate(
580
580
  temporary = output.with_name(f".{output.stem}.base.cif")
581
581
  try:
582
582
  render_candidate(parsed, occupancies, target, target_counts, z, temporary)
583
- from pymatgen.core import Structure
584
-
585
- structure = Structure.from_file(temporary)
583
+ structure = _cifio.read_structure(temporary)
586
584
  structure.make_supercell(list(matrix))
587
585
  _cifio.write_cif(structure, output, data_name=output.stem, refine=False)
588
586
  finally:
@@ -730,7 +728,7 @@ def run(args):
730
728
  f"input structure(s) to {output_root}."
731
729
  )
732
730
  _emit(report, summary)
733
- report_path = output_root / "realloc-occ.txt"
731
+ report_path = Path.cwd() / "realloc-occ.txt"
734
732
  report_path.write_text("\n".join(report) + "\n", encoding="utf-8")
735
733
  print(f"Wrote {report_path}")
736
734
  return 0 if inputs_done == len(sources) else 1
@@ -207,11 +207,9 @@ def read_cif_labels(path):
207
207
 
208
208
  def read_structure(path):
209
209
  """Read any pymatgen-readable structure, falling back to ASE."""
210
- from pymatgen.core import Structure
211
-
212
210
  path = Path(path)
213
211
  try:
214
- return Structure.from_file(str(path))
212
+ return _cifio.read_structure(path)
215
213
  except Exception as pymatgen_error:
216
214
  if _cifio.is_cif(path):
217
215
  raise ValueError(
@@ -0,0 +1,253 @@
1
+ """pmk dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import io
8
+ import importlib
9
+ import os
10
+ import pkgutil
11
+ import shlex
12
+ import sys
13
+ import traceback
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Dict, Tuple
17
+
18
+ from . import __version__, commands
19
+ from ._config import load_config
20
+
21
+ DESCRIPTION = "A python data processing toolkit for molecular dynamics and high-throughput calculations"
22
+
23
+
24
+ def _read_meta(path: Path, modname: str) -> Tuple[str, str]:
25
+ command, help_text = modname, ""
26
+ try:
27
+ tree = ast.parse(path.read_text(encoding="utf-8-sig"))
28
+ except (OSError, SyntaxError):
29
+ return command, help_text
30
+ for node in tree.body:
31
+ if isinstance(node, ast.Assign):
32
+ for target in node.targets:
33
+ if (isinstance(target, ast.Name)
34
+ and isinstance(node.value, ast.Constant)
35
+ and isinstance(node.value.value, str)):
36
+ if target.id == "COMMAND":
37
+ command = node.value.value
38
+ elif target.id == "HELP":
39
+ help_text = node.value.value
40
+ if not help_text:
41
+ doc = (ast.get_docstring(tree) or "").strip()
42
+ help_text = doc.splitlines()[0] if doc else ""
43
+ return command, help_text
44
+
45
+
46
+ def _discover() -> Dict[str, Tuple[str, str]]:
47
+ pkg_dir = Path(commands.__path__[0])
48
+ found: Dict[str, Tuple[str, str]] = {}
49
+ for info in pkgutil.iter_modules([str(pkg_dir)]):
50
+ if info.name.startswith("_"):
51
+ continue
52
+ command, help_text = _read_meta(pkg_dir / f"{info.name}.py", info.name)
53
+ found[command] = (info.name, help_text)
54
+ return found
55
+
56
+
57
+ def _add_help(parser: argparse.ArgumentParser) -> None:
58
+ parser.add_argument("-h", "-help", action="help",
59
+ help="show this help message and exit")
60
+
61
+
62
+ def print_main_help(cmds) -> None:
63
+ print("pmk")
64
+ print()
65
+ print(DESCRIPTION)
66
+ print()
67
+ print(f"Version {__version__}")
68
+ print()
69
+ print("Usage: pmk <command> -[options]")
70
+ print()
71
+ print("Available commands:")
72
+ print()
73
+ for name in sorted(cmds):
74
+ print(f" {name:<20} {cmds[name][1]}")
75
+ print()
76
+ print("Command details: pmk <command> -h")
77
+ print()
78
+ print("Options:")
79
+ print(" -h, -help show this help message and exit")
80
+ print(" -v, -version show program's version number and exit")
81
+
82
+
83
+ def _build_top_parser(cmds: Dict[str, Tuple[str, str]]) -> argparse.ArgumentParser:
84
+ parser = argparse.ArgumentParser(add_help=False)
85
+ _add_help(parser)
86
+ parser.add_argument("-v", "-version", action="version", version=f"pmk {__version__}")
87
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
88
+ for name in sorted(cmds):
89
+ sub.add_parser(name, help=cmds[name][1], add_help=False)
90
+ return parser
91
+
92
+
93
+ class _OutputRecorder(io.TextIOBase):
94
+ """Record output while preserving normal live terminal behavior."""
95
+
96
+ def __init__(self, terminal, *, echo: bool):
97
+ self.terminal = terminal
98
+ self.echo = echo
99
+ self.buffer = io.StringIO()
100
+
101
+ @property
102
+ def encoding(self):
103
+ return getattr(self.terminal, "encoding", "utf-8")
104
+
105
+ def isatty(self):
106
+ return bool(getattr(self.terminal, "isatty", lambda: False)())
107
+
108
+ def write(self, text):
109
+ self.buffer.write(text)
110
+ if self.echo:
111
+ self.terminal.write(text)
112
+ self.terminal.flush()
113
+ return len(text)
114
+
115
+ def flush(self):
116
+ if self.echo:
117
+ self.terminal.flush()
118
+
119
+ def getvalue(self):
120
+ return self.buffer.getvalue()
121
+
122
+
123
+ def _command_text(argv) -> str:
124
+ return shlex.join(["pmk", *argv])
125
+
126
+
127
+ def _write_command_log(command: str, argv, output: str, status: int,
128
+ previous: str, native_report: str) -> Path:
129
+ path = Path.cwd() / f"{command}.txt"
130
+ separator = "=" * 80
131
+ lines = [
132
+ separator,
133
+ f"Run: {datetime.now().astimezone().isoformat(timespec='seconds')}",
134
+ f"Command: {_command_text(argv)}",
135
+ f"Status: {'success' if status == 0 else 'failed'} (exit code {status})",
136
+ "-" * 80,
137
+ ]
138
+ details = output.rstrip()
139
+ if details:
140
+ lines.extend(("Output:", details))
141
+ if native_report.strip():
142
+ lines.extend(("Report:", native_report.rstrip()))
143
+ if not details and not native_report.strip():
144
+ lines.append("No screen output.")
145
+ block = "\n".join(lines) + "\n"
146
+ combined = previous.rstrip()
147
+ if combined:
148
+ combined += "\n\n"
149
+ combined += block
150
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
151
+ temporary.write_text(combined, encoding="utf-8")
152
+ temporary.replace(path)
153
+ return path
154
+
155
+
156
+ def _native_report(path: Path, previous: str) -> str:
157
+ if not path.exists():
158
+ return ""
159
+ current = path.read_text(encoding="utf-8", errors="replace")
160
+ if current == previous:
161
+ return ""
162
+ if previous and current.startswith(previous):
163
+ return current[len(previous):].lstrip("\r\n")
164
+ return current
165
+
166
+
167
+ def _run_command(command: str, argv, module, parser, *, silence: bool) -> int:
168
+ log_path = Path.cwd() / f"{command}.txt"
169
+ previous = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
170
+ terminal_out, terminal_err = sys.stdout, sys.stderr
171
+ recorder = _OutputRecorder(terminal_out, echo=not silence)
172
+ status = 0
173
+ pending_exception = None
174
+ exception_text = ""
175
+ try:
176
+ sys.stdout = recorder
177
+ sys.stderr = recorder
178
+ try:
179
+ args = parser.parse_args(argv[1:])
180
+ result = module.run(args)
181
+ status = 0 if result is None else int(result)
182
+ except SystemExit as exc:
183
+ status = exc.code if isinstance(exc.code, int) else 1
184
+ if exc.code and not isinstance(exc.code, int):
185
+ print(exc.code)
186
+ except Exception:
187
+ status = 1
188
+ pending_exception = sys.exc_info()
189
+ exception_text = traceback.format_exc()
190
+ recorder.buffer.write(exception_text)
191
+ finally:
192
+ sys.stdout = terminal_out
193
+ sys.stderr = terminal_err
194
+
195
+ output = recorder.getvalue()
196
+ native_report = _native_report(log_path, previous)
197
+ try:
198
+ _write_command_log(command, argv, output, status, previous, native_report)
199
+ except Exception as exc:
200
+ print(f"Error: could not write {log_path}: {exc}", file=terminal_err)
201
+ if status == 0:
202
+ status = 1
203
+ if silence and status != 0 and pending_exception is None:
204
+ message = output or f"Error: {_command_text(argv)} failed with exit code {status}.\n"
205
+ terminal_err.write(message)
206
+ terminal_err.flush()
207
+ if pending_exception is not None:
208
+ _, error, tb = pending_exception
209
+ raise error.with_traceback(tb)
210
+ return status
211
+
212
+
213
+ def main(argv=None) -> int:
214
+ argv = list(sys.argv[1:] if argv is None else argv)
215
+ cmds = _discover()
216
+ top = _build_top_parser(cmds)
217
+
218
+ if not argv:
219
+ print_main_help(cmds)
220
+ return 1
221
+ if argv[0] in ("-h", "-help"):
222
+ print_main_help(cmds)
223
+ return 0
224
+ if argv[0] in ("-v", "-version"):
225
+ top.parse_args(argv)
226
+ return 0
227
+
228
+ command = argv[0]
229
+ if command not in cmds:
230
+ print_main_help(cmds)
231
+ raise SystemExit(f"\nError: invalid command {command!r}")
232
+
233
+ modname = cmds[command][0]
234
+ module = importlib.import_module(f"{commands.__name__}.{modname}")
235
+ cmd_parser = argparse.ArgumentParser(
236
+ prog=f"pmk {command}",
237
+ description=getattr(module, "HELP", None) or (module.__doc__ or "").strip(),
238
+ add_help=False,
239
+ )
240
+ _add_help(cmd_parser)
241
+ if hasattr(module, "add_arguments"):
242
+ module.add_arguments(cmd_parser)
243
+ try:
244
+ settings = load_config()
245
+ except (OSError, ValueError) as exc:
246
+ print(f"Error: could not load PMK configuration: {exc}", file=sys.stderr)
247
+ return 2
248
+ return _run_command(command, argv, module, cmd_parser,
249
+ silence=settings["silence"])
250
+
251
+
252
+ if __name__ == "__main__":
253
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymdkit
3
- Version: 1.6.2
3
+ Version: 1.6.4
4
4
  Summary: A unified command-line toolkit for atomistic / MD structure workflows.
5
5
  Author-email: Yueda Wang <ydwang0608@ustc.edu.cn>
6
6
  License-Expression: GPL-3.0-or-later
@@ -63,6 +63,27 @@ pmk -help # lists every command
63
63
  pmk <command> -help # shows that command's flags
64
64
  ```
65
65
 
66
+ ## Global configuration and logs
67
+
68
+ On first use, PMK creates `.pmkrc.yaml` in the active Python environment root.
69
+ For example, an executable at `~/anaconda3/envs/pymdkit/bin/pmk` uses
70
+ `~/anaconda3/envs/pymdkit/.pmkrc.yaml`. View the active file and settings with:
71
+
72
+ ```bash
73
+ pmk config
74
+ pmk config silence -on
75
+ pmk config silence -off
76
+ ```
77
+
78
+ With silence enabled, successful commands do not print to the terminal. Errors
79
+ that prevent an operation are still shown. Command details are never discarded:
80
+ every `pmk <command> ...` invocation appends its exact command, timestamp, exit
81
+ status, and screen output to `<command>.txt` in the directory where PMK was
82
+ invoked. Repeated runs remain in chronological order in the same file. This log
83
+ always stays in the invocation directory even when `-of` selects an output
84
+ folder. Existing command reports that use the same filename, such as `rmsd.txt`
85
+ and `ehull.txt`, are preserved as the report section of that run.
86
+
66
87
  ## Commands
67
88
 
68
89
  Commands that transform structures accept either a single file (`-i`/`-o`) or a
@@ -71,6 +92,7 @@ directory for job sub-folders automatically.
71
92
 
72
93
  | Command | What it does |
73
94
  |---|---|
95
+ | `config` | View or edit global PMK settings in `.pmkrc.yaml` |
74
96
  | `add-config-type` | Add Config_type to one XYZ structure or every trajectory frame |
75
97
  | `gpumd-group` | Tag atoms with a GPUMD group index by element order |
76
98
  | `gpumd-relax` | Write GPUMD energy-minimization jobs for a file, folder, or trajectory |
@@ -265,7 +287,7 @@ first, until every multiplicity times occupancy times supercell determinant is
265
287
  an integer. Equal-volume choices favor the most isotropic resulting cell.
266
288
  All selections, site atom counts, oxidation states, charges, failures, the
267
289
  allowed occupancy grid, and the final summary are recorded in
268
- `OUTPUT/realloc-occ.txt` for both single-file and folder modes.
290
+ `realloc-occ.txt` in the invocation directory for both single-file and folder modes.
269
291
 
270
292
  `gather-fs` recursively scans all job folders below the current directory. For
271
293
  VASP it follows the global `vaspout.h5 > vasprun.xml > OUTCAR` priority and
@@ -336,8 +358,14 @@ CIF input with pymatgen rather than ASE. ASE interprets
336
358
  `_space_group_crystal_system` as a setting hint only for rhombohedral groups
337
359
  and emits a potentially misleading warning for other crystal systems. Using
338
360
  pymatgen avoids that ambiguity while PMK continues to use ASE for non-CIF
339
- formats. A partially occupied CIF must first be ordered with `convert -d2o`
340
- before it can be represented as an explicit atom list.
361
+ formats. Pymatgen intentionally snaps fractional coordinates within its
362
+ default `1e-4` tolerance to ideal values before applying symmetry. PMK keeps
363
+ that corrective behavior but hides only its corresponding finite-precision
364
+ notice; composition, occupancy, malformed-CIF, and all other warnings remain
365
+ visible. See the
366
+ [pymatgen CifParser documentation](https://pymatgen.org/pymatgen.io.html#pymatgen.io.cif.CifParser).
367
+ A partially occupied CIF must first be ordered with `convert -d2o` before it
368
+ can be represented as an explicit atom list.
341
369
 
342
370
  CIF `_atom_type_symbol` and `_atom_site_label` values use per-element
343
371
  inequivalent-site identifiers such as `Li1`, `Y1`, `Cl1`, and `Cl2`.
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/pymdkit/__init__.py
5
+ src/pymdkit/_config.py
5
6
  src/pymdkit/pymdkit_main.py
6
7
  src/pymdkit.egg-info/PKG-INFO
7
8
  src/pymdkit.egg-info/SOURCES.txt
@@ -27,6 +28,7 @@ src/pymdkit/commands/add_config_type.py
27
28
  src/pymdkit/commands/chemsys_entry.py
28
29
  src/pymdkit/commands/compute_ehull.py
29
30
  src/pymdkit/commands/compute_rmsd.py
31
+ src/pymdkit/commands/config.py
30
32
  src/pymdkit/commands/convert.py
31
33
  src/pymdkit/commands/ewald.py
32
34
  src/pymdkit/commands/gather_fs.py
@@ -49,6 +51,7 @@ src/pymdkit/commands/vasp2xyz.py
49
51
  src/pymdkit/commands/vasp_fe.py
50
52
  src/pymdkit/commands/vasp_relax.py
51
53
  src/pymdkit/commands/vasp_static.py
54
+ tests/test_config_logging.py
52
55
  tests/test_convert.py
53
56
  tests/test_lattice_symmetry.py
54
57
  tests/test_realloc_occ.py
@@ -0,0 +1,97 @@
1
+ """Tests for PMK global configuration and command logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import tempfile
8
+ import unittest
9
+ from contextlib import redirect_stderr, redirect_stdout
10
+ from pathlib import Path
11
+ from unittest.mock import patch
12
+
13
+ from pymdkit import _config
14
+ from pymdkit import pymdkit_main
15
+
16
+
17
+ class WorkingDirectory:
18
+ def __init__(self, path):
19
+ self.path = Path(path)
20
+ self.previous = None
21
+
22
+ def __enter__(self):
23
+ self.previous = Path.cwd()
24
+ os.chdir(self.path)
25
+ return self.path
26
+
27
+ def __exit__(self, *exc_info):
28
+ os.chdir(self.previous)
29
+
30
+
31
+ class ConfigTests(unittest.TestCase):
32
+ def test_default_path_uses_active_environment_root(self):
33
+ with patch.object(_config.sys, "prefix", r"C:\envs\pymdkit"), \
34
+ patch.dict(os.environ, {}, clear=True):
35
+ self.assertEqual(
36
+ _config.config_path(),
37
+ Path(r"C:\envs\pymdkit").resolve() / ".pmkrc.yaml",
38
+ )
39
+
40
+ def test_config_command_silence_and_append_only_log(self):
41
+ with tempfile.TemporaryDirectory() as tmp, WorkingDirectory(tmp):
42
+ config_file = Path(tmp) / "environment" / ".pmkrc.yaml"
43
+ environment = {"PMK_CONFIG_FILE": str(config_file)}
44
+ with patch.dict(os.environ, environment, clear=False):
45
+ stdout, stderr = io.StringIO(), io.StringIO()
46
+ with redirect_stdout(stdout), redirect_stderr(stderr):
47
+ self.assertEqual(
48
+ pymdkit_main.main(["config", "silence", "-on"]), 0
49
+ )
50
+ self.assertIn("silence: on", stdout.getvalue())
51
+ self.assertIn("silence: true", config_file.read_text(encoding="utf-8"))
52
+
53
+ stdout, stderr = io.StringIO(), io.StringIO()
54
+ with redirect_stdout(stdout), redirect_stderr(stderr):
55
+ self.assertEqual(pymdkit_main.main(["config"]), 0)
56
+ self.assertEqual(stdout.getvalue(), "")
57
+ self.assertEqual(stderr.getvalue(), "")
58
+
59
+ stdout, stderr = io.StringIO(), io.StringIO()
60
+ with redirect_stdout(stdout), redirect_stderr(stderr):
61
+ self.assertEqual(pymdkit_main.main(["config", "silence"]), 2)
62
+ self.assertEqual(stdout.getvalue(), "")
63
+ self.assertIn("requires -on or -off", stderr.getvalue())
64
+
65
+ stdout, stderr = io.StringIO(), io.StringIO()
66
+ with redirect_stdout(stdout), redirect_stderr(stderr):
67
+ self.assertEqual(
68
+ pymdkit_main.main(["config", "silence", "-off"]), 0
69
+ )
70
+ self.assertEqual(stdout.getvalue(), "")
71
+ self.assertIn("silence: false", config_file.read_text(encoding="utf-8"))
72
+
73
+ report = (Path(tmp) / "config.txt").read_text(encoding="utf-8")
74
+ self.assertIn("Command: pmk config silence -on", report)
75
+ self.assertIn("Command: pmk config silence -off", report)
76
+ self.assertEqual(report.count("Command:"), 4)
77
+ self.assertIn("Status: failed (exit code 2)", report)
78
+
79
+ def test_native_report_is_preserved_in_command_log(self):
80
+ with tempfile.TemporaryDirectory() as tmp, WorkingDirectory(tmp):
81
+ path = Path(tmp) / "rmsd.txt"
82
+ previous = "older run\n"
83
+ path.write_text(previous, encoding="utf-8")
84
+ path.write_text("new RMSD details\n", encoding="utf-8")
85
+ native = pymdkit_main._native_report(path, previous)
86
+ pymdkit_main._write_command_log(
87
+ "rmsd", ["rmsd", "-if", "structures"], "Wrote rmsd.txt\n",
88
+ 0, previous, native,
89
+ )
90
+ report = path.read_text(encoding="utf-8")
91
+ self.assertIn("older run", report)
92
+ self.assertIn("Command: pmk rmsd -if structures", report)
93
+ self.assertIn("Report:\nnew RMSD details", report)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ unittest.main()
@@ -2,6 +2,7 @@ import math
2
2
  import sys
3
3
  import types
4
4
  import unittest
5
+ import warnings
5
6
  from pathlib import Path
6
7
  from unittest.mock import patch
7
8
 
@@ -38,6 +39,39 @@ class FakeLattice:
38
39
  return math.degrees(math.acos(float(np.clip(cosine, -1.0, 1.0))))
39
40
 
40
41
 
42
+ class CifReaderTests(unittest.TestCase):
43
+ def test_only_benign_fractional_rounding_warning_is_hidden(self):
44
+ structure = object()
45
+
46
+ class Structure:
47
+ @classmethod
48
+ def from_file(cls, path):
49
+ warnings.warn(
50
+ "Issues encountered while parsing CIF: 4 fractional "
51
+ "coordinates rounded to ideal values to avoid issues "
52
+ "with finite precision.",
53
+ UserWarning,
54
+ )
55
+ warnings.warn("CIF composition mismatch", UserWarning)
56
+ return structure
57
+
58
+ pymatgen = types.ModuleType("pymatgen")
59
+ core = types.ModuleType("pymatgen.core")
60
+ core.Structure = Structure
61
+ pymatgen.core = core
62
+ with patch.dict(sys.modules, {
63
+ "pymatgen": pymatgen,
64
+ "pymatgen.core": core,
65
+ }), warnings.catch_warnings(record=True) as caught:
66
+ warnings.simplefilter("always")
67
+ result = _cifio.read_structure("input.cif")
68
+
69
+ self.assertIs(result, structure)
70
+ self.assertEqual([str(item.message) for item in caught], [
71
+ "CIF composition mismatch"
72
+ ])
73
+
74
+
41
75
  class LatticeGeometryTests(unittest.TestCase):
42
76
  def test_gpumd_uses_pymatgen_lattice_row_vectors(self):
43
77
  pymatgen = types.ModuleType("pymatgen")
@@ -169,13 +169,13 @@ class ReallocOccTests(unittest.TestCase):
169
169
  occ=[0.25, 0.5, 0.75, 1.0],
170
170
  max_supercell="2x2x2",
171
171
  )
172
- with patch.object(
172
+ with patch.object(realloc_occ.Path, "cwd", return_value=root), patch.object(
173
173
  realloc_occ._oxidation,
174
174
  "infer_oxidation_states",
175
175
  return_value=OXIDATION,
176
176
  ):
177
177
  self.assertEqual(realloc_occ.run(args), 0)
178
- report = (output / "realloc-occ.txt").read_text(encoding="utf-8")
178
+ report = (root / "realloc-occ.txt").read_text(encoding="utf-8")
179
179
  self.assertIn(
180
180
  "Allowed occupancies: 0.250, 0.500, 0.750, 1.000",
181
181
  report,
@@ -1,123 +0,0 @@
1
- """pmk dispatcher."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import ast
7
- import importlib
8
- import pkgutil
9
- import sys
10
- from pathlib import Path
11
- from typing import Dict, Tuple
12
-
13
- from . import __version__, commands
14
-
15
- DESCRIPTION = "A python data processing toolkit for molecular dynamics and high-throughput calculations"
16
-
17
-
18
- def _read_meta(path: Path, modname: str) -> Tuple[str, str]:
19
- command, help_text = modname, ""
20
- try:
21
- tree = ast.parse(path.read_text(encoding="utf-8-sig"))
22
- except (OSError, SyntaxError):
23
- return command, help_text
24
- for node in tree.body:
25
- if isinstance(node, ast.Assign):
26
- for target in node.targets:
27
- if (isinstance(target, ast.Name)
28
- and isinstance(node.value, ast.Constant)
29
- and isinstance(node.value.value, str)):
30
- if target.id == "COMMAND":
31
- command = node.value.value
32
- elif target.id == "HELP":
33
- help_text = node.value.value
34
- if not help_text:
35
- doc = (ast.get_docstring(tree) or "").strip()
36
- help_text = doc.splitlines()[0] if doc else ""
37
- return command, help_text
38
-
39
-
40
- def _discover() -> Dict[str, Tuple[str, str]]:
41
- pkg_dir = Path(commands.__path__[0])
42
- found: Dict[str, Tuple[str, str]] = {}
43
- for info in pkgutil.iter_modules([str(pkg_dir)]):
44
- if info.name.startswith("_"):
45
- continue
46
- command, help_text = _read_meta(pkg_dir / f"{info.name}.py", info.name)
47
- found[command] = (info.name, help_text)
48
- return found
49
-
50
-
51
- def _add_help(parser: argparse.ArgumentParser) -> None:
52
- parser.add_argument("-h", "-help", action="help",
53
- help="show this help message and exit")
54
-
55
-
56
- def print_main_help(cmds) -> None:
57
- print("pmk")
58
- print()
59
- print(DESCRIPTION)
60
- print()
61
- print(f"Version {__version__}")
62
- print()
63
- print("Usage: pmk <command> -[options]")
64
- print()
65
- print("Available commands:")
66
- print()
67
- for name in sorted(cmds):
68
- print(f" {name:<20} {cmds[name][1]}")
69
- print()
70
- print("Command details: pmk <command> -h")
71
- print()
72
- print("Options:")
73
- print(" -h, -help show this help message and exit")
74
- print(" -v, -version show program's version number and exit")
75
-
76
-
77
- def _build_top_parser(cmds: Dict[str, Tuple[str, str]]) -> argparse.ArgumentParser:
78
- parser = argparse.ArgumentParser(add_help=False)
79
- _add_help(parser)
80
- parser.add_argument("-v", "-version", action="version", version=f"pmk {__version__}")
81
- sub = parser.add_subparsers(dest="command", metavar="<command>")
82
- for name in sorted(cmds):
83
- sub.add_parser(name, help=cmds[name][1], add_help=False)
84
- return parser
85
-
86
-
87
- def main(argv=None) -> int:
88
- argv = list(sys.argv[1:] if argv is None else argv)
89
- cmds = _discover()
90
- top = _build_top_parser(cmds)
91
-
92
- if not argv:
93
- print_main_help(cmds)
94
- return 1
95
- if argv[0] in ("-h", "-help"):
96
- print_main_help(cmds)
97
- return 0
98
- if argv[0] in ("-v", "-version"):
99
- top.parse_args(argv)
100
- return 0
101
-
102
- command = argv[0]
103
- if command not in cmds:
104
- print_main_help(cmds)
105
- raise SystemExit(f"\nError: invalid command {command!r}")
106
-
107
- modname = cmds[command][0]
108
- module = importlib.import_module(f"{commands.__name__}.{modname}")
109
- cmd_parser = argparse.ArgumentParser(
110
- prog=f"pmk {command}",
111
- description=getattr(module, "HELP", None) or (module.__doc__ or "").strip(),
112
- add_help=False,
113
- )
114
- _add_help(cmd_parser)
115
- if hasattr(module, "add_arguments"):
116
- module.add_arguments(cmd_parser)
117
- args = cmd_parser.parse_args(argv[1:])
118
- result = module.run(args)
119
- return 0 if result is None else int(result)
120
-
121
-
122
- if __name__ == "__main__":
123
- raise SystemExit(main())
File without changes
File without changes
File without changes
File without changes