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 +3 -0
- appm/__version__.py +1 -0
- appm/exceptions.py +13 -0
- appm/manager.py +187 -0
- appm/model.py +82 -0
- appm/utils.py +27 -0
- appm-0.0.1.dist-info/METADATA +9 -0
- appm-0.0.1.dist-info/RECORD +9 -0
- appm-0.0.1.dist-info/WHEEL +4 -0
appm/__init__.py
ADDED
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
|
+
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,,
|