appm 0.0.1__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.
appm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from appm.manager import ProjectManager
2
+
3
+ __all__ = ("ProjectManager",)
appm/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
appm/exceptions.py ADDED
@@ -0,0 +1,13 @@
1
+ class TemplateEngineException(Exception): ...
2
+
3
+
4
+ class ProjectNotFoundErr(TemplateEngineException): ...
5
+
6
+
7
+ class FileFormatMismatch(TemplateEngineException): ...
8
+
9
+
10
+ class MetadataFileNotFoundErr(TemplateEngineException): ...
11
+
12
+
13
+ class UnsupportedFileExtension(TemplateEngineException): ...
appm/manager.py ADDED
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any, Sequence, overload
8
+
9
+ from ruamel.yaml import YAML, CommentedMap, CommentedSeq
10
+
11
+ from appm.__version__ import __version__
12
+ from appm.exceptions import (
13
+ FileFormatMismatch,
14
+ MetadataFileNotFoundErr,
15
+ ProjectNotFoundErr,
16
+ UnsupportedFileExtension,
17
+ )
18
+ from appm.model import ExtDecl, ProjectMetadata
19
+
20
+ yaml = YAML()
21
+ yaml.indent(mapping=2, sequence=4, offset=2)
22
+ yaml.preserve_quotes = True # optional, if you want to preserve quotes
23
+
24
+
25
+ def to_flow_style(obj: Any) -> Any:
26
+ """Recursively convert dict/list to ruamel structures with ALL lists using flow-style."""
27
+ if isinstance(obj, Mapping):
28
+ cm = CommentedMap()
29
+ for k, v in obj.items():
30
+ cm[k] = to_flow_style(v)
31
+ return cm
32
+ if isinstance(obj, Sequence) and not isinstance(obj, str):
33
+ cs = CommentedSeq()
34
+ for item in obj:
35
+ cs.append(to_flow_style(item))
36
+ cs.fa.set_flow_style()
37
+ return cs
38
+ return obj
39
+
40
+
41
+ class ExtManager:
42
+ def __init__(self, ext: str, decl: ExtDecl) -> None:
43
+ self.ext = ext
44
+ self.decl = decl
45
+
46
+ @property
47
+ def pattern(self) -> str:
48
+ return (
49
+ r"^"
50
+ + self.decl.sep.join([f"({p})" for _, p in self.decl.format])
51
+ + r"(.*)$"
52
+ )
53
+
54
+ def match(self, name: str) -> dict[str, str]:
55
+ match = re.match(self.pattern, name)
56
+ if not match:
57
+ raise FileFormatMismatch(f"Name: {name}. Pattern: {self.pattern}")
58
+ groups = match.groups()
59
+ result = {}
60
+ for i, (field, _) in enumerate(self.decl.format):
61
+ result[field] = groups[i]
62
+ result["*"] = groups[-1]
63
+ return result
64
+
65
+
66
+ class ProjectManager:
67
+ METADATA_NAME: str = "metadata.yaml"
68
+
69
+ def __init__(
70
+ self,
71
+ metadata: dict[str, Any],
72
+ root: str | Path,
73
+ ) -> None:
74
+ self.root = Path(root)
75
+ self.metadata = ProjectMetadata.model_validate(metadata)
76
+ self.handlers = {
77
+ ext: ExtManager(ext, ext_decl)
78
+ for ext, ext_decl in self.metadata.file.items()
79
+ }
80
+
81
+ def match(self, name: str) -> dict[str, str]:
82
+ ext = name.split(".")[-1]
83
+ if ext not in self.handlers:
84
+ raise UnsupportedFileExtension(ext)
85
+ return self.handlers[ext].match(name)
86
+
87
+ def get_file_placement(self, name: str) -> str:
88
+ layout = self.metadata.layout
89
+ groups = self.match(name)
90
+ values = [groups[component] for component in layout]
91
+ return "/".join(values)
92
+
93
+ def init_project(self) -> None:
94
+ self.root.mkdir(exist_ok=True, parents=True)
95
+ self.save_metadata()
96
+
97
+ def save_metadata(self) -> None:
98
+ metadata_path = self.root / self.METADATA_NAME
99
+ with metadata_path.open("w") as file:
100
+ data = self.metadata.model_dump(mode="json")
101
+ data["version"] = __version__
102
+ yaml.dump(
103
+ to_flow_style(data),
104
+ file,
105
+ )
106
+
107
+ def copy_file(self, src_path: Path) -> None:
108
+ if src_path.exists():
109
+ raise FileNotFoundError(str(src_path))
110
+ location = self.get_file_placement(src_path.name)
111
+ dst_path = self.root / location
112
+ dst_path.mkdir(parents=True, exist_ok=True)
113
+ shutil.copy2(src_path, dst_path)
114
+
115
+ @overload
116
+ @classmethod
117
+ def from_metadata(
118
+ cls,
119
+ root: str | Path,
120
+ metadata: str | Path,
121
+ year: int,
122
+ summary: str,
123
+ internal: bool = True,
124
+ researcher: str | None = None,
125
+ organisation: str | None = None,
126
+ ) -> ProjectManager: ...
127
+
128
+ @overload
129
+ @classmethod
130
+ def from_metadata(
131
+ cls,
132
+ root: str | Path,
133
+ metadata: dict[str, Any],
134
+ year: int,
135
+ summary: str,
136
+ internal: bool = True,
137
+ researcher: str | None = None,
138
+ organisation: str | None = None,
139
+ ) -> ProjectManager: ...
140
+
141
+ @classmethod
142
+ def from_metadata(
143
+ cls,
144
+ root: str | Path,
145
+ metadata: str | Path | dict[str, Any],
146
+ year: int,
147
+ summary: str,
148
+ internal: bool = True,
149
+ researcher: str | None = None,
150
+ organisation: str | None = None,
151
+ ) -> ProjectManager:
152
+ if isinstance(metadata, str | Path):
153
+ metadata = Path(metadata)
154
+ if not metadata.exists():
155
+ raise MetadataFileNotFoundErr(str(metadata))
156
+ with metadata.open("r") as file:
157
+ _metadata = yaml.load(file)
158
+ else:
159
+ _metadata = metadata
160
+ _metadata.update(
161
+ {
162
+ "year": year,
163
+ "summary": summary,
164
+ "internal": internal,
165
+ "researcher": researcher,
166
+ "organisation": organisation,
167
+ }
168
+ )
169
+ return cls(root=root, metadata=_metadata)
170
+
171
+ @classmethod
172
+ def load_project(
173
+ cls, project_path: Path | str, metadata_name: str | None = None
174
+ ) -> ProjectManager:
175
+ project_path = Path(project_path)
176
+ if not project_path.exists():
177
+ raise ProjectNotFoundErr(str(project_path))
178
+ metadata_path = (
179
+ project_path / cls.METADATA_NAME
180
+ if not metadata_name
181
+ else project_path / metadata_name
182
+ )
183
+ if not metadata_path.exists():
184
+ raise MetadataFileNotFoundErr(str(metadata_path))
185
+ with metadata_path.open("r") as file:
186
+ metadata = yaml.load(file)
187
+ return cls(metadata=metadata, root=project_path)
appm/model.py ADDED
@@ -0,0 +1,82 @@
1
+ from typing import Self
2
+
3
+ from pydantic import BaseModel, model_validator
4
+
5
+ from appm.__version__ import __version__
6
+ from appm.utils import slugify
7
+
8
+ STRUCTURES = {"year", "summary", "internal", "researcher", "organisation"}
9
+
10
+
11
+ class ExtDecl(BaseModel):
12
+ sep: str = "_"
13
+ format: list[tuple[str, str]]
14
+
15
+ @property
16
+ def fields(self) -> set[str]:
17
+ return {item[0] for item in self.format}
18
+
19
+
20
+ class NamingConventionDecl(BaseModel):
21
+ sep: str = "_"
22
+ structure: list[str] = ["year", "summary", "internal", "researcher", "organisation"]
23
+
24
+ @model_validator(mode="after")
25
+ def validate_structure_values(self) -> Self:
26
+ counter: dict[str, int] = {}
27
+ if len(self.structure) == 0:
28
+ raise ValueError("Invalid naming structure - empty structure")
29
+ for field in self.structure:
30
+ counter[field] = counter.get(field, 0) + 1
31
+ if counter[field] > 1:
32
+ raise ValueError(f"Invalid naming structure - repetition: {field}")
33
+ if field not in STRUCTURES:
34
+ raise ValueError(
35
+ f"Invalid naming structure - invalid field: {field}. Structure must be a non empty permutation of {STRUCTURES}"
36
+ )
37
+ return self
38
+
39
+
40
+ class ProjectDecl(BaseModel):
41
+ layout: list[str]
42
+ file: dict[str, ExtDecl]
43
+ naming_convention: NamingConventionDecl = NamingConventionDecl()
44
+
45
+ @property
46
+ def layout_set(self) -> set[str]:
47
+ return set(self.layout)
48
+
49
+ @model_validator(mode="after")
50
+ def validate_format_and_layout(self) -> Self:
51
+ for ext, decl in self.file.items():
52
+ if not self.layout_set.issubset(decl.fields):
53
+ raise ValueError(
54
+ f"""Format fields must be a superset of layout fields.
55
+ Extension: {ext}. Format fields: {decl.fields}. Layout fields: {self.layout_set}"""
56
+ )
57
+ return self
58
+
59
+
60
+ class ProjectMetadata(ProjectDecl):
61
+ year: int
62
+ summary: str
63
+ internal: bool
64
+ researcher: str | None = None
65
+ organisation: str | None = None
66
+ version: str | None = __version__
67
+
68
+ @property
69
+ def name(self) -> str:
70
+ fields = self.naming_convention.structure
71
+ name: list[str] = []
72
+ for field in fields:
73
+ value = getattr(self, field)
74
+ if value is not None:
75
+ if isinstance(value, str):
76
+ name.append(slugify(value))
77
+ elif field == "year":
78
+ name.append(str(value))
79
+ elif field == "internal":
80
+ value = "internal" if value else "external"
81
+ name.append(value)
82
+ return self.naming_convention.sep.join(name)
appm/utils.py ADDED
@@ -0,0 +1,27 @@
1
+ import re
2
+
3
+
4
+ def slugify(text: str) -> str:
5
+ """Generate a slug from a text
6
+
7
+ Used for generating project name and url slug
8
+
9
+ https://developer.mozilla.org/en-US/docs/Glossary/Slug
10
+
11
+ Example:
12
+ - The Plant Accelerator -> the-plant-accelerator
13
+
14
+ - APPN -> appn
15
+
16
+ Args:
17
+ text (str): source text
18
+
19
+ Returns:
20
+ str: slug
21
+ """
22
+ text = text.lower()
23
+ # Replace non slug characters
24
+ text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)
25
+ # Replace spaces with hyphens
26
+ text = re.sub(r"[\s\-]+", "-", text)
27
+ return text.strip("-")
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.3
2
+ Name: appm
3
+ Version: 0.0.1
4
+ Summary: APPN Project Manager Package
5
+ Requires-Dist: pydantic>=2.11.7
6
+ Requires-Dist: ruamel-yaml>=0.18.14
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
@@ -0,0 +1,9 @@
1
+ appm/__init__.py,sha256=70a098d1ac000a7e4f9aa72fa542e63b165b884837cf4e5c31a56af7df6fc6db,71
2
+ appm/__version__.py,sha256=b172e1ee0dca0b84021717191814e91b6b1c47b866981b0c8eae8ba91a6d9118,22
3
+ appm/exceptions.py,sha256=3be0e11cfa9cef94fbf2c442f81fc571760f33e26b6fc0b1201b1961057d4d9d,285
4
+ appm/manager.py,sha256=7e1add7d357300aa5e9287bf7683f4ec623ca02cd683f252526f0f416646a594,5633
5
+ appm/model.py,sha256=d5a928f0c07b4927933de7b93fb2efaa3dc631318fe0dcd9b2b14b98d0a4aa1b,2708
6
+ appm/utils.py,sha256=f72c5864d51ce2e37734c7724ca076fb5e5a5ce6d427ca6eeebd10d27511351e,578
7
+ appm-0.0.1.dist-info/WHEEL,sha256=b70116f4076fa664af162441d2ba3754dbb4ec63e09d563bdc1e9ab023cce400,78
8
+ appm-0.0.1.dist-info/METADATA,sha256=408a8aad2d5a60df543a2a24d0323e82534a866cabf8ce75fab757291fb89361,219
9
+ appm-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any