hadalized 0.4.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.
hadalized/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI app for generating color themes for specific applications."""
hadalized/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running hadalized cli via `python -m hadalized`."""
2
+
3
+ from hadalized.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
hadalized/base.py ADDED
@@ -0,0 +1,137 @@
1
+ """Base container for all model classes."""
2
+
3
+ from typing import ClassVar, Self
4
+
5
+ from pydantic import PrivateAttr
6
+ from pydantic_settings import (
7
+ BaseSettings,
8
+ PydanticBaseSettingsSource,
9
+ SettingsConfigDict,
10
+ # TomlConfigSettingsSource,
11
+ )
12
+
13
+ from hadalized.const import APP_NAME, APP_VERSION
14
+
15
+
16
+ class BaseNode(BaseSettings):
17
+ """An extension of BaseSettings that all model classes inherit.
18
+
19
+ Unless overriden, by default only initialization settings are respected.
20
+
21
+ Full setting sources are exposed in the ``UserConfig`` subclass.
22
+ """
23
+
24
+ model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
25
+ frozen=True,
26
+ extra="forbid",
27
+ )
28
+
29
+ _hash: int | None = PrivateAttr(default=None)
30
+ """Cached hash computation so that instances can be passed to cached
31
+ functions and used in dicts."""
32
+
33
+ @classmethod
34
+ def settings_customise_sources(
35
+ cls,
36
+ settings_cls: type[BaseSettings],
37
+ init_settings: PydanticBaseSettingsSource,
38
+ env_settings: PydanticBaseSettingsSource,
39
+ dotenv_settings: PydanticBaseSettingsSource,
40
+ file_secret_settings: PydanticBaseSettingsSource,
41
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
42
+ """Set source loading priority.
43
+
44
+ Returns:
45
+ Priority order in which config settings are loaded.
46
+
47
+ """
48
+ return (init_settings,)
49
+
50
+ @property
51
+ def app_info(self) -> str:
52
+ """App name and version."""
53
+ return f"{APP_NAME} v{APP_VERSION}"
54
+
55
+ def model_dump_lua(self) -> str:
56
+ """Dump the model as a lua table.
57
+
58
+ Returns:
59
+ A human readable lua table string.
60
+
61
+ """
62
+ import luadata
63
+
64
+ # TODO: Unclear if we want to import luadata just for this
65
+ return luadata.serialize(self.model_dump(mode="json"), indent=" ")
66
+
67
+ def replace(self, **kwargs) -> Self:
68
+ """Create a new instance with input arguments merged in.
69
+
70
+ Returns:
71
+ A new instance.
72
+
73
+ """
74
+ return self.model_validate(self.model_dump() | kwargs)
75
+
76
+ def __getitem__(self, key: str):
77
+ """Provide dict-like lookup for all models.
78
+
79
+ Returns:
80
+ The field specified by the input key.
81
+
82
+ """
83
+ return getattr(self, key)
84
+
85
+ def __hash__(self) -> int:
86
+ """Make an instance hashable for use in cache and dict lookups.
87
+
88
+ Defined for type checking purposes. Frozen models are hashable.
89
+
90
+ Returns:
91
+ The BaseModel hash.
92
+
93
+ """
94
+ if self._hash is None:
95
+ self._hash = hash(self.model_dump_json())
96
+ return self._hash
97
+
98
+ def __len__(self) -> int:
99
+ """Report the number of model fields.
100
+
101
+ Returns:
102
+ The length of the set of model fields.
103
+
104
+ """
105
+ return len(self.__class__.model_fields)
106
+
107
+ def __or__(self, other: BaseNode) -> Self:
108
+ """Shallow merge explicitly set fields.
109
+
110
+ Only the explicitly set fields of ``other`` are merged in.
111
+
112
+ Args:
113
+ other: An instance of the same type or parent type to ``self``.
114
+
115
+ Returns:
116
+ A new instance with the set fields of `other` merged in.
117
+
118
+ """
119
+ merged = self.model_dump(exclude_unset=True) | other.model_dump(
120
+ exclude_unset=True
121
+ )
122
+ return self.model_validate(merged)
123
+
124
+ # def merge(self, parent: BaseNode) -> Self:
125
+ # """Shallow merge a parent instance.
126
+ #
127
+ # Args:
128
+ # parent: A model with a subset of the fields defined in the
129
+ # instance.
130
+ #
131
+ # Returns:
132
+ # A new instance of the same type as the instance with the set
133
+ # parent fields.
134
+ #
135
+ # """
136
+ # merged = self.model_dump() | parent.model_dump(exclude_unset=True)
137
+ # return self.model_validate(merged)
hadalized/cache.py ADDED
@@ -0,0 +1,121 @@
1
+ """Application cache layer."""
2
+
3
+ import sqlite3
4
+ from typing import TYPE_CHECKING, Self
5
+
6
+ from loguru import logger
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+ from hadalized.options import Options
12
+
13
+
14
+ class Cache:
15
+ """Caching layer."""
16
+
17
+ def __init__(self, opts: Options | None = None):
18
+ """Create a new instance.
19
+
20
+ Args:
21
+ opts: Runtime options controlling location of cache, whether to
22
+ use it, etc.
23
+
24
+ """
25
+ self.opt = opts or Options()
26
+ self.cache_dir: Path = self.opt.cache_dir
27
+ self._db_file: Path = self.cache_dir / "builds.db"
28
+ if not self.opt.cache_in_memory:
29
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
30
+ self._conn: sqlite3.Connection
31
+
32
+ def _setup(self):
33
+ with self._conn:
34
+ self._conn.execute("""
35
+ CREATE TABLE IF NOT EXISTS
36
+ digests(path TEXT PRIMARY KEY, digest TEXT)""")
37
+ # self._conn.execute("""
38
+ # CREATE TABLE IF NOT EXISTS
39
+ # palettes(name TEXT PRIMARY KEY, data TEXT)""")
40
+
41
+ def connect(self) -> Self:
42
+ """Connect to the underlying sqlite database.
43
+
44
+ Returns:
45
+ The connected instance.
46
+
47
+ """
48
+ file = ":memory:" if self.opt.cache_in_memory else self._db_file
49
+ self._conn = sqlite3.connect(file)
50
+ self._setup()
51
+ return self
52
+
53
+ def close(self):
54
+ """Close the database connection."""
55
+ self._conn.close()
56
+
57
+ def add(self, path: str | Path, digest: str):
58
+ """Add a (path, hash hexdigest) to the database.
59
+
60
+ Used after a file is successfully generated to store a proxy for
61
+ the contents of the generated file.
62
+ """
63
+ with self._conn:
64
+ self._conn.execute(
65
+ """INSERT INTO digests VALUES(:path, :digest)
66
+ ON CONFLICT(path) DO UPDATE SET digest=:digest""",
67
+ {
68
+ "path": str(path),
69
+ "digest": digest,
70
+ },
71
+ )
72
+
73
+ def delete(self, path: str | Path):
74
+ """Remove a cache entry."""
75
+ with self._conn:
76
+ self._conn.execute(
77
+ """DELETE FROM digests WHERE path == ?""",
78
+ [str(path)],
79
+ )
80
+
81
+ def get(self, path: str | Path) -> str | None:
82
+ """Get a build digest for the input path.
83
+
84
+ Returns:
85
+ A hash (proxy) of the previously generated file or None if no
86
+ record is found.
87
+
88
+ """
89
+ with self._conn:
90
+ cur = self._conn.execute(
91
+ """SELECT digest FROM digests WHERE path == ? LIMIT 1""",
92
+ [str(path)],
93
+ )
94
+ data = cur.fetchone()
95
+ return data[0] if isinstance(data, tuple) else None
96
+
97
+ def get_digests(self) -> dict[str, str]:
98
+ """Obtain the contents of all cache build digests as a mapping.
99
+
100
+ Returns:
101
+ Mapping of path -> hash digest for all paths in the cache.
102
+
103
+ """
104
+ with self._conn:
105
+ return dict(self._conn.execute("SELECT * from digests"))
106
+
107
+ def __enter__(self) -> Self:
108
+ """Connect to the database.
109
+
110
+ Returns:
111
+ The connected instance.
112
+
113
+ """
114
+ self.connect()
115
+ return self
116
+
117
+ def __exit__(self, exc_type, exc_value, traceback):
118
+ """Close the database connection."""
119
+ if exc_type is not None:
120
+ logger.error((exc_type, exc_value, traceback))
121
+ self.close()
@@ -0,0 +1 @@
1
+ """Command line interface for hadalized theme builder."""
hadalized/cli/main.py ADDED
@@ -0,0 +1,221 @@
1
+ """Application commands."""
2
+
3
+ from contextlib import suppress
4
+ from pathlib import Path # noqa
5
+ from shutil import rmtree
6
+
7
+ from cyclopts import App
8
+
9
+ from hadalized import homedirs
10
+ from hadalized.cache import Cache
11
+ from hadalized.config import Config, Options, load_config
12
+ from hadalized.writer import ThemeWriter
13
+
14
+ app = App()
15
+ cache_app = app.command(App(name="cache", help="Interact with the application cache."))
16
+ config_app = app.command(
17
+ App(name="config", help="Interact with the application config.")
18
+ )
19
+ palette_app = app.command(App(name="palette", help="Interact with palettes."))
20
+ state_app = app.command(
21
+ App(name="state", help="Interact with the application state, e.g., built files.")
22
+ )
23
+
24
+
25
+ @app.command
26
+ def build(name: str | None = None, opt: Options | None = None):
27
+ """Build application color themes files.
28
+
29
+ When no applications or palette is specified, themes will be built for all
30
+ application and palette pairs.
31
+
32
+ Args:
33
+ name: Target application to build. Use ``--app`` to include multiple.
34
+ opt: Options.
35
+
36
+ """
37
+ opt = opt or Options()
38
+ if name is not None:
39
+ opt |= Options(include_builds=[*opt.include_builds, name])
40
+ config = load_config(opt)
41
+ if config.dry_run:
42
+ print("DRY-RUN. No theme files will be generated or copied.")
43
+ with ThemeWriter(config) as writer:
44
+ writer.run()
45
+
46
+
47
+ # @config_app.command(name="info")
48
+ # def config_info(opt: Options | None = None):
49
+ # """Dispaly configuration info."""
50
+ # from rich import print_json
51
+ #
52
+ # config = load_config(opt)
53
+ # print_json(config.model_dump_json())
54
+
55
+
56
+ @config_app.command(name="schema")
57
+ def config_schema():
58
+ """Display configuration schema."""
59
+ import json
60
+
61
+ from rich import print_json
62
+
63
+ print_json(json.dumps(Config.model_json_schema()))
64
+
65
+
66
+ @config_app.command(name="init")
67
+ def config_init(opts: Options | None = None):
68
+ """Populate application configuration toml file.
69
+
70
+ When `--output=stdout` the toml contents will be printed.
71
+ """
72
+ import tomli_w as toml
73
+
74
+ config = load_config(opts)
75
+ if str(config.output_dir) == "stdout":
76
+ print(toml.dumps(config.model_dump(mode="json", exclude_none=True)))
77
+ return
78
+ output = config.output_dir or homedirs.config()
79
+ if output.suffix != ".toml":
80
+ output /= "config.toml"
81
+
82
+ output_exists = output.exists()
83
+ if output_exists and not config.quiet:
84
+ print(f"{output} already exists.")
85
+ if output_exists and not config.force:
86
+ return
87
+
88
+ output.parent.mkdir(parents=True, exist_ok=True)
89
+ with output.open("wb") as fp:
90
+ if not config.quiet:
91
+ print(f"Creating {output}")
92
+ data = config.model_dump(mode="json", exclude_none=True)
93
+ if not config.dry_run:
94
+ toml.dump(data, fp)
95
+ # except TypeError as exc:
96
+ # print(f"Unable to write config file: {exc}")
97
+ # output.unlink()
98
+
99
+
100
+ @palette_app.command(name="parse")
101
+ def palette_parse(
102
+ name: str = "hadalized",
103
+ *,
104
+ gamut: str | None = None,
105
+ opt: Options | None = None,
106
+ ):
107
+ """Show information about a particular palette.
108
+
109
+ Args:
110
+ name: Palette name or alias, e.g., "hadalized".
111
+ gamut: A specifed gamut to parse against. If not provided, the
112
+ gamut defined by the palette is used.
113
+ opt: Options
114
+
115
+ """
116
+ # TODO: Respect user config.
117
+ from rich import print_json
118
+
119
+ opt = opt or Options()
120
+ config = load_config(opt)
121
+ for item in [name, *config.include_palettes]:
122
+ palette = config.get_palette(item)
123
+ if gamut is not None:
124
+ palette = palette.replace(gamut=gamut)
125
+ print_json(palette.parse().model_dump_json())
126
+
127
+
128
+ @cache_app.command(name="clean")
129
+ def cache_clean(opt: Options | None = None):
130
+ """Clear the application cache."""
131
+ config = load_config(opt)
132
+ if config.dry_run and not config.quiet:
133
+ print("DRY-RUN: Cache files will not be deleted.")
134
+ if not config.quiet:
135
+ print(f"Clearing {config.cache_dir}")
136
+ if config.verbose:
137
+ files = "\n".join(str(x) for x in config.cache_dir.glob("**/*") if x.is_file())
138
+ print(files)
139
+ if not config.dry_run:
140
+ with suppress(FileNotFoundError):
141
+ rmtree(config.cache_dir)
142
+
143
+
144
+ @cache_app.command(name="dir")
145
+ def cache_dir(opt: Options | None = None):
146
+ """Show the cache directory."""
147
+ config = load_config(opt)
148
+ print(config.cache_dir)
149
+
150
+
151
+ @cache_app.command(name="list", alias=["ls"])
152
+ def cache_list(opt: Options | None = None):
153
+ """List the contents of the application cache."""
154
+ import json
155
+
156
+ from rich import print_json
157
+
158
+ config = load_config(opt)
159
+
160
+ with Cache(config.opt) as cache:
161
+ print_json(json.dumps(cache.get_digests()))
162
+
163
+
164
+ @state_app.command(name="dir")
165
+ def state_dir(opt: Options | None = None):
166
+ """Show the applicate state directory."""
167
+ config = load_config(opt)
168
+ print(config.opt.state_dir)
169
+
170
+
171
+ @state_app.command(name="clean")
172
+ def state_clean(opt: Options | None = None):
173
+ """Clear application state files such as built themes."""
174
+ config = load_config(opt)
175
+ if config.dry_run and not config.quiet:
176
+ print("DRY-RUN. No state files will be deleted.")
177
+ if not config.quiet:
178
+ # import json
179
+ #
180
+ # from rich import print_json
181
+
182
+ print(f"Clearing {config.state_dir}")
183
+ files = "\n".join(str(x) for x in config.state_dir.glob("**/*") if x.is_file())
184
+ if files:
185
+ print(files)
186
+ # print_json(json.dumps(files))
187
+ if not config.dry_run:
188
+ with suppress(FileNotFoundError):
189
+ rmtree(config.state_dir)
190
+
191
+
192
+ @state_app.command(name="list", alias=["ls"])
193
+ def state_list(opt: Options | None = None):
194
+ """List application state files."""
195
+ config = load_config(opt)
196
+ files = "\n".join(str(x) for x in config.state_dir.glob("**/*") if x.is_file())
197
+ print(files)
198
+
199
+
200
+ @app.command
201
+ def clean(opt: Options | None = None):
202
+ """Clean cache and state files."""
203
+ cache_clean(opt)
204
+ state_clean(opt)
205
+
206
+
207
+ # @app.command
208
+ # def debug(opt: Options | None = None):
209
+ # """Debug things."""
210
+ # from rich import print_json
211
+ #
212
+ # config = load_config(opt)
213
+ # print(f"{config.cache_dir=}")
214
+ # print(f"{config.dry_run=}")
215
+ # print(f"{opt=}")
216
+ # if opt is not None:
217
+ # print(f"{opt.model_fields_set=}")
218
+ # print("config.opt")
219
+ # print_json(config.opt.model_dump_json())
220
+ # print(f"{config.model_fields_set=}")
221
+ # print(f"{config.opt.model_fields_set=}")