Skip to content

Commit 01e7df3

Browse files
authored
Merge pull request #64 from martindurant/cli-refactor
Make CLI subcommands; make config more flexible
2 parents a6e1adc + fbb3b7e commit 01e7df3

File tree

13 files changed

+655
-107
lines changed

13 files changed

+655
-107
lines changed

src/projspec/__main__.py

Lines changed: 164 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,23 @@
1010
import projspec.proj
1111

1212

13-
@click.command()
14-
@click.argument("path", default=".")
13+
# global runtime config
14+
context = {}
15+
16+
17+
@click.group()
18+
def main():
19+
pass
20+
21+
22+
@main.command("make")
23+
@click.argument("artifact", type=str)
24+
@click.argument("path", default=".", type=str)
25+
@click.option(
26+
"--storage_options",
27+
default="",
28+
help="storage options dict for the given URL, as JSON",
29+
)
1530
@click.option(
1631
"--types",
1732
default="ALL",
@@ -22,81 +37,175 @@
2237
default="NONE",
2338
help="List of spec types to ignore (comma-separated list in camel or snake case)",
2439
)
25-
@click.option("--walk", is_flag=True, help="To descend into all child directories")
26-
@click.option("--summary", is_flag=True, help="Show abbreviated output")
27-
@click.option("--version", is_flag=True, default=False, help="Print version and quit")
40+
def make(artifact, path, storage_options, types, xtypes):
41+
"""Make the given artifact in the project at the given path.
42+
43+
artifact: str , of the form [<spec>.]<artifact-type>[.<name>]
44+
45+
path: str, path to the project directory, defaults to "."
46+
"""
47+
if types in {"ALL", ""}:
48+
types = None
49+
else:
50+
types = types.split(",")
51+
proj = projspec.Project(
52+
path, storage_options=storage_options, types=types, xtypes=xtypes
53+
)
54+
proj.make(artifact)
55+
56+
57+
@main.command()
58+
def version():
59+
print(f"projspec version: {projspec.__version__}")
60+
61+
62+
@main.command("scan")
63+
@click.argument("path", default=".")
2864
@click.option(
29-
"--json-out", is_flag=True, default=False, help="JSON output, for projects only"
65+
"--storage_options",
66+
default="",
67+
help="storage options dict for the given URL, as JSON",
3068
)
3169
@click.option(
32-
"--html-out", is_flag=True, default=False, help="HTML output, for projects only"
70+
"--types",
71+
default="ALL",
72+
help='Type names to scan for (comma-separated list in camel or snake case); defaults to "ALL"',
3373
)
3474
@click.option(
35-
"--make",
36-
help="(Re)Create the first artifact found matching this type name; matches [spec.]artifact[.name]",
75+
"--xtypes",
76+
default="NONE",
77+
help="List of spec types to ignore (comma-separated list in camel or snake case)",
3778
)
3879
@click.option(
39-
"--info",
40-
help="Give information about a names entity type (spec, contents or artifact)",
80+
"--json-out", is_flag=True, default=False, help="JSON output, for projects only"
4181
)
4282
@click.option(
43-
"--storage_options",
44-
default="",
45-
help="storage options dict for the given URL, as JSON",
83+
"--html-out", is_flag=True, default=False, help="HTML output, for projects only"
4684
)
47-
def main(
48-
path,
49-
types,
50-
xtypes,
51-
walk,
52-
summary,
53-
version,
54-
json_out,
55-
html_out,
56-
make,
57-
info,
58-
storage_options,
85+
@click.option("--walk", is_flag=True, help="To descend into all child directories")
86+
@click.option("--summary", is_flag=True, help="Show abbreviated output")
87+
@click.option("--library", is_flag=True, help="Add to library")
88+
def scan(
89+
path, storage_options, types, xtypes, json_out, html_out, walk, summary, library
5990
):
60-
if version:
61-
print(projspec.__version__)
62-
return
91+
"""Scan the given path for projects, and display
92+
93+
path: str, path to the project directory, defaults to "."
94+
"""
6395
if types in {"ALL", ""}:
6496
types = None
6597
else:
6698
types = types.split(",")
67-
if info:
68-
info = projspec.utils.camel_to_snake(info)
99+
proj = projspec.Project(
100+
path, storage_options=storage_options, types=types, xtypes=xtypes, walk=walk
101+
)
102+
if summary:
103+
print(proj.text_summary())
104+
else:
105+
if json_out:
106+
print(json.dumps(proj.to_dict(compact=True)))
107+
elif html_out:
108+
print(proj._repr_html_())
109+
else:
110+
print(proj)
111+
if library:
112+
proj.add_to_library()
113+
114+
115+
@main.command("info")
116+
@click.argument(
117+
"types",
118+
default="ALL",
119+
)
120+
def info(types=None):
121+
if types in {"ALL", "", None}:
122+
from projspec.utils import class_infos
123+
124+
print(json.dumps(class_infos()))
125+
else:
126+
name = projspec.utils.camel_to_snake(types)
69127
cls = (
70-
projspec.proj.base.registry.get(info)
71-
or projspec.content.base.registry.get(info)
72-
or projspec.artifact.base.registry.get(info)
128+
projspec.proj.base.registry.get(name)
129+
or projspec.content.base.registry.get(name)
130+
or projspec.artifact.base.registry.get(name)
73131
)
74132
if cls:
75133
pydoc.doc(cls, output=sys.stdout)
76134
else:
77135
print("Name not found")
78-
return
79-
if xtypes in {"NONE", ""}:
80-
xtypes = None
81-
else:
82-
xtypes = xtypes.split(",")
83-
if storage_options:
84-
storage_options = json.loads(storage_options)
85-
else:
86-
storage_options = None
87-
proj = projspec.Project(
88-
path, storage_options=storage_options, types=types, xtypes=xtypes, walk=walk
89-
)
90-
if make:
91-
proj.make(make)
92-
elif summary:
93-
print(proj.text_summary())
94-
elif json_out:
95-
print(json.dumps(proj.to_dict(compact=True)))
96-
elif html_out:
97-
print(proj._repr_html_())
136+
137+
138+
@main.group("library")
139+
def library():
140+
"""Interact with the project library.
141+
142+
Library file location is defined by config value "library_path".
143+
"""
144+
145+
146+
@library.command("list")
147+
@click.option(
148+
"--json-out", is_flag=True, default=False, help="JSON output, for projects only"
149+
)
150+
def list(json_out):
151+
from projspec.library import ProjectLibrary
152+
153+
library = ProjectLibrary()
154+
if json_out:
155+
print(json.dumps({k: v.to_dict() for k, v in library.entries.items()}))
98156
else:
99-
print(proj)
157+
for url, proj in library.entries.items():
158+
print(f"{proj.text_summary(bare=True)}")
159+
160+
161+
@library.command("delete")
162+
@click.argument("url")
163+
def delete(url):
164+
from projspec.library import ProjectLibrary
165+
166+
library = ProjectLibrary()
167+
library.entries.pop(url)
168+
library.save()
169+
170+
171+
@main.group("config")
172+
def config():
173+
"""Interact with the projspec config."""
174+
pass
175+
176+
177+
@config.command("get")
178+
@click.argument("key")
179+
def get(key):
180+
from projspec.config import get_conf
181+
182+
print(get_conf(key))
183+
184+
185+
@config.command("show")
186+
def show():
187+
from projspec.config import conf
188+
189+
# TODO: show docs and defaults for each key, from projspec.config.config_doc?
190+
# TODO: allow JSON output
191+
print(conf)
192+
193+
194+
@config.command("unset")
195+
@click.argument("key")
196+
def unset(key):
197+
from projspec.config import set_conf
198+
199+
set_conf(key, None)
200+
201+
202+
@config.command("set")
203+
@click.argument("key")
204+
@click.argument("value")
205+
def set_(key, value):
206+
from projspec.config import set_conf
207+
208+
set_conf(key, value)
100209

101210

102211
if __name__ == "__main__":

src/projspec/artifact/base.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import fsspec.implementations.local
66

7+
from projspec.config import get_conf
78
from projspec.proj import Project
89
from projspec.utils import camel_to_snake, is_installed
910

@@ -34,13 +35,16 @@ def _check_runner(self):
3435
return self.cmd[0] in is_installed
3536

3637
@property
37-
def state(self) -> Literal["clean", "done", "pending"]:
38-
if self._is_clean():
39-
return "clean"
40-
elif self._is_done():
41-
return "done"
38+
def state(self) -> Literal["clean", "done", "pending", ""]:
39+
if get_conf("remote_artifact_status"):
40+
if self._is_clean():
41+
return "clean"
42+
elif self._is_done():
43+
return "done"
44+
else:
45+
return "pending"
4246
else:
43-
return "pending"
47+
return ""
4448

4549
def make(self, *args, **kwargs):
4650
"""Create the artifact and any runtime it depends on"""

src/projspec/config.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,39 @@
1+
from contextlib import contextmanager
12
import json
23
import os
34

45
from typing import Any
56

67
conf: dict[str, dict[str, Any]] = {}
78
default_conf_dir = os.path.join(os.path.expanduser("~"), ".config/projspec")
8-
conf_dir = os.environ.get("PROJSPEC_CONFIG_DIR", default_conf_dir)
9-
10-
defaults = {
11-
# location of persisted project objects
12-
"library_path": f"{conf_dir}/library.json",
13-
# files automatically read before scanning
14-
"scan_types": [".py", ".yaml", ".yml", ".toml", ".json"],
15-
# don't scan files if more than this number in the project
16-
"scan_max_files": 100,
17-
# don't scan files bigger than this (in bytes)
18-
# In the future we may change this to reading this many bytes from the header.
19-
"scan_max_size": 5 * 2**10,
9+
10+
11+
def conf_dir():
12+
return os.environ.get("PROJSPEC_CONFIG_DIR", default_conf_dir)
13+
14+
15+
def defaults():
16+
return {
17+
"library_path": f"{conf_dir()}/library.json",
18+
"scan_types": [".py", ".yaml", ".yml", ".toml", ".json"],
19+
"scan_max_files": 100,
20+
"scan_max_size": 5 * 2**10,
21+
"remote_artifact_status": False,
22+
}
23+
24+
25+
config_doc = {
26+
"library_path": "location of persisted project objects",
27+
"scan_types": "files extensions automatically read for scanning",
28+
"scan_max_files": "don't scan files if more than this number in the project",
29+
"scan_max_size": "don't scan files bigger than this (in bytes)",
30+
"remote_artifact_status": "whether to check status for remote artifacts",
2031
}
2132

2233

2334
def load_conf(path: str | None = None):
24-
fn = f"{path or default_conf_dir}/projspec.json"
35+
fn = f"{path or conf_dir()}/projspec.json"
36+
conf.clear()
2537
if os.path.exists(fn):
2638
with open(fn) as f:
2739
conf.update(json.load(f))
@@ -32,4 +44,29 @@ def load_conf(path: str | None = None):
3244

3345
def get_conf(name: str):
3446
"""Fetch the value of the given conf parameter from the current config or defaults"""
35-
return conf[name] if name in conf else defaults[name]
47+
return conf[name] if name in conf else defaults()[name]
48+
49+
50+
def set_conf(name: str, value: Any):
51+
"""Set the value of the given conf parameter and save to the config file"""
52+
# TODO: require new value to be of same type as default?
53+
if value:
54+
conf[name] = value
55+
else:
56+
conf.pop(name, None)
57+
os.makedirs(conf_dir(), exist_ok=True)
58+
with open(f"{conf_dir()}/projspec.json", "wt") as f:
59+
json.dump(conf, f)
60+
61+
62+
@contextmanager
63+
def temp_conf(**kwargs):
64+
"""Temporarily set the config"""
65+
old = conf.copy()
66+
# TODO: only allow keys that exist in defaults()?
67+
conf.update(kwargs)
68+
try:
69+
yield
70+
finally:
71+
conf.clear()
72+
conf.update(old)

src/projspec/library.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ class ProjectLibrary:
1919

2020
def __init__(self, library_path: str | None = None, auto_save: bool = True):
2121
self.path = library_path or get_conf("library_path")
22-
self.load()
2322
self.entries: dict[str, Project] = {}
2423
self.auto_save = auto_save
24+
self.load()
2525

2626
def load(self):
2727
"""Loads scanned project objects from JSON file"""
@@ -68,7 +68,3 @@ def _match(proj: Project, filters: list[tuple[str, str | tuple[str]]]) -> bool:
6868
if cat == "content" and not proj.all_contents(value):
6969
return False
7070
return True
71-
72-
73-
library = ProjectLibrary()
74-
library.load()

0 commit comments

Comments
 (0)