stooge 0.1.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.
- port/__init__.py +11 -0
- port/cli.py +46 -0
- port/console.py +6 -0
- port/constants.py +12 -0
- port/data/project_template/a010_first_script.py +6 -0
- port/data/project_template/local.py +38 -0
- port/data/project_template/utils.py +3 -0
- port/data/spf_example/a010_clean_data.py +21 -0
- port/data/spf_example/a020_calculate.py +25 -0
- port/data/spf_example/a030_visualize.py +38 -0
- port/data/spf_example/local.py +36 -0
- port/data/spf_example/utils.py +20 -0
- port/exceptions.py +11 -0
- port/info.py +24 -0
- port/init.py +53 -0
- port/meta.py +42 -0
- port/project.py +73 -0
- port/utils/__init__.py +213 -0
- port/utils/dag.py +61 -0
- port/utils/parse.py +237 -0
- port/version.py +4 -0
- stooge-0.1.0.dist-info/METADATA +152 -0
- stooge-0.1.0.dist-info/RECORD +27 -0
- stooge-0.1.0.dist-info/WHEEL +5 -0
- stooge-0.1.0.dist-info/entry_points.txt +2 -0
- stooge-0.1.0.dist-info/licenses/LICENSE +21 -0
- stooge-0.1.0.dist-info/top_level.txt +1 -0
port/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""port package exports."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from port.info import info
|
|
6
|
+
from port.init import init
|
|
7
|
+
from port.version import __version__
|
|
8
|
+
|
|
9
|
+
_template_path = Path(__file__).parent / "data" / "project_template"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__", "_template_path", "info", "init"]
|
port/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Port's CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from port.info import info as info_project
|
|
14
|
+
from port.init import init as init_project
|
|
15
|
+
from port.version import __version__
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="CLI for port project scaffolding.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("init")
|
|
21
|
+
def init(path: Optional[Path] = typer.Argument(None)):
|
|
22
|
+
"""Create or initialize a port project directory."""
|
|
23
|
+
project_path = init_project(path)
|
|
24
|
+
typer.echo(str(project_path))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("info")
|
|
28
|
+
def info(path: Optional[Path] = typer.Argument(None)):
|
|
29
|
+
"""Show metadata for a port project."""
|
|
30
|
+
data = info_project(path)
|
|
31
|
+
typer.echo(json.dumps(data))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("version")
|
|
35
|
+
def version():
|
|
36
|
+
"""Print the installed port version."""
|
|
37
|
+
typer.echo(__version__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
"""Entrypoint used by executable wrappers."""
|
|
42
|
+
app()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
port/console.py
ADDED
port/constants.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# {{ project_name }}
|
|
3
|
+
|
|
4
|
+
#### {Insert description of project} ####
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# --- Project functions
|
|
9
|
+
# These are used in more than one script. Consider creating a separate utils
|
|
10
|
+
# module if this section gets too large. However, never import any project
|
|
11
|
+
# modules in local.py.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_folder_exists(path: Path) -> Path:
|
|
15
|
+
"""
|
|
16
|
+
Given a path to a directory, ensure the directory exists.
|
|
17
|
+
|
|
18
|
+
Returns a path to said directory.
|
|
19
|
+
"""
|
|
20
|
+
path = Path(path)
|
|
21
|
+
path.mkdir(exist_ok=True, parents=True)
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- Project Parameters
|
|
26
|
+
|
|
27
|
+
# The base path for this project
|
|
28
|
+
project_path = Path(__file__).parent
|
|
29
|
+
|
|
30
|
+
# --- Inputs to project
|
|
31
|
+
|
|
32
|
+
input_path = ensure_folder_exists(project_path / "inputs")
|
|
33
|
+
|
|
34
|
+
# --- Outputs produced by project
|
|
35
|
+
|
|
36
|
+
# outputs should have the same prefix as the script which creates them (eg a010)
|
|
37
|
+
|
|
38
|
+
output_path = ensure_folder_exists(project_path / "outputs")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The first script to run
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from local import cleaned_csv, earthquake_csv
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def clean_earthquake_df(df):
|
|
10
|
+
"""Clean the earthquake dataframe."""
|
|
11
|
+
# drop any rows with lat/long out of range or invalid event_id
|
|
12
|
+
bad_lat = abs(df["longitude"]) > 180
|
|
13
|
+
bad_long = abs(df["latitude"]) > 90
|
|
14
|
+
out = df[~(bad_long | bad_lat)].dropna(axis="rows", subset=["event_id"])
|
|
15
|
+
return out
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
df = pd.read_csv(earthquake_csv)
|
|
20
|
+
out = clean_earthquake_df(df)
|
|
21
|
+
out.to_csv(cleaned_csv, index=False)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This script simply adds a column to the dataframe.
|
|
3
|
+
"""
|
|
4
|
+
import local
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_summary_string(df):
|
|
9
|
+
"""Add a column to the df which is useful for displaying."""
|
|
10
|
+
|
|
11
|
+
def _get_summary_str(ser):
|
|
12
|
+
time_str = str(ser["time"])[:19]
|
|
13
|
+
depth_str = f"Depth={ser['depth']/1_000}km"
|
|
14
|
+
mag_str = f"ML={ser['magnitude']:02f}"
|
|
15
|
+
out = f"{time_str} {depth_str} {mag_str}"
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
df["summary_string"] = df.apply(_get_summary_str, axis=1)
|
|
19
|
+
return df
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
df = pd.read_csv(local.cleaned_csv)
|
|
24
|
+
out = add_summary_string(df)
|
|
25
|
+
out.to_csv(local.calc_csv)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Script to create simple visualizations of earthquake data.
|
|
3
|
+
"""
|
|
4
|
+
import local
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from local import magnitude_time_plot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def plot_magnitude_histogram(df):
|
|
11
|
+
"""Plot a histogram of magnitudes, return mpl figure."""
|
|
12
|
+
fig, ax = plt.subplots(1, 1)
|
|
13
|
+
ax.hist(df["magnitude"])
|
|
14
|
+
ax.set_xlabel("Local Magnitude")
|
|
15
|
+
ax.set_ylabel("Count")
|
|
16
|
+
plt.tight_layout()
|
|
17
|
+
return fig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def time_mag_dot_plot(df):
|
|
21
|
+
"""Create a dot plot of magnitude and time."""
|
|
22
|
+
time = pd.to_datetime(df["time"])
|
|
23
|
+
fig, ax = plt.subplots(1, 1)
|
|
24
|
+
ax.plot(time, df["magnitude"], ".")
|
|
25
|
+
ax.set_xlabel("Time")
|
|
26
|
+
ax.set_ylabel("Local Magnitude")
|
|
27
|
+
plt.tight_layout()
|
|
28
|
+
return fig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
df = pd.read_csv(local.calc_csv)
|
|
33
|
+
# plot magnitude histogram
|
|
34
|
+
fig = plot_magnitude_histogram(df)
|
|
35
|
+
fig.savefig(local.magnitude_histogram)
|
|
36
|
+
# plot magnitude vs time
|
|
37
|
+
fig = time_mag_dot_plot(df)
|
|
38
|
+
fig.savefig(magnitude_time_plot)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project variables used by the example project.
|
|
3
|
+
"""
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# --- Project functions
|
|
7
|
+
# These are used in more than one script. Consider creating a separate utils
|
|
8
|
+
# module if this section gets too large.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_folder_exists(path: Path) -> Path:
|
|
12
|
+
"""
|
|
13
|
+
Given a path to a directory, ensure the directory exists.
|
|
14
|
+
|
|
15
|
+
Returns a path to said directory.
|
|
16
|
+
"""
|
|
17
|
+
path = Path(path)
|
|
18
|
+
path.mkdir(exist_ok=True, parents=True)
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# The base path for this project
|
|
23
|
+
project_path = Path(__file__).parent
|
|
24
|
+
|
|
25
|
+
# --- Inputs to project
|
|
26
|
+
|
|
27
|
+
input_path = ensure_folder_exists(project_path / "inputs")
|
|
28
|
+
earthquake_csv = input_path / "bingham_earthquakes.csv"
|
|
29
|
+
|
|
30
|
+
# --- Outputs produced by project
|
|
31
|
+
|
|
32
|
+
output_path = ensure_folder_exists(project_path / "outputs")
|
|
33
|
+
cleaned_csv = output_path / "a010_cleaned_earthquakes.csv"
|
|
34
|
+
calc_csv = output_path / "a020_calculated_earthquakes.csv"
|
|
35
|
+
magnitude_histogram = output_path / "a030_magnitude_hist.png"
|
|
36
|
+
magnitude_time_plot = output_path / "a030_magnitude_time_plot"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example utilites
|
|
3
|
+
"""
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure_folder_exists(path: Union[Path, str]) -> Path:
|
|
9
|
+
"""
|
|
10
|
+
Given a path to a directory, ensure the directory exists.
|
|
11
|
+
|
|
12
|
+
Returns a path to said directory.
|
|
13
|
+
"""
|
|
14
|
+
path = Path(path)
|
|
15
|
+
path.mkdir(exist_ok=True, parents=True)
|
|
16
|
+
return path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def some_function():
|
|
20
|
+
"""Another function which might be used by multiple files."""
|
port/exceptions.py
ADDED
port/info.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Helpers for getting metadata from a port project."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
from port.meta import read_metadata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def info(project_path: Optional[Union[Path, str]] = None) -> dict:
|
|
10
|
+
"""
|
|
11
|
+
Return metadata for a project.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
project_path
|
|
16
|
+
Path to project directory. Defaults to current working directory.
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
dict
|
|
21
|
+
Metadata dictionary for the project.
|
|
22
|
+
"""
|
|
23
|
+
data = read_metadata(project_path)
|
|
24
|
+
return data
|
port/init.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Code for creating new port projects."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
import jinja2
|
|
7
|
+
|
|
8
|
+
import port
|
|
9
|
+
from port.meta import write_metadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _init_directory(template_directory: Path, output_directory: Path, data: dict) -> None:
|
|
13
|
+
"""Render each template file into the output directory."""
|
|
14
|
+
for path in Path(template_directory).rglob("*"):
|
|
15
|
+
if path.is_dir():
|
|
16
|
+
continue
|
|
17
|
+
template_contents = path.read_text()
|
|
18
|
+
template = jinja2.Template(template_contents)
|
|
19
|
+
out = template.render(data)
|
|
20
|
+
new_path = output_directory / path.relative_to(template_directory)
|
|
21
|
+
if new_path.exists():
|
|
22
|
+
continue
|
|
23
|
+
new_path.parent.mkdir(exist_ok=True, parents=True)
|
|
24
|
+
# Do not copy example script if an a010 script already exists.
|
|
25
|
+
is_a010 = new_path.name.startswith("a010")
|
|
26
|
+
has_a010 = bool(list(output_directory.glob("a010*.py")))
|
|
27
|
+
if is_a010 and has_a010:
|
|
28
|
+
continue
|
|
29
|
+
with new_path.open("w") as fi:
|
|
30
|
+
fi.write(out)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init(path: Optional[Union[Path, str]] = None) -> Path:
|
|
34
|
+
"""
|
|
35
|
+
Initialize a port project in a target directory.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
path
|
|
40
|
+
The target directory path. If it does not exist, it is created.
|
|
41
|
+
If not provided, use the current working directory.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
Path
|
|
46
|
+
Path to the initialized project directory.
|
|
47
|
+
"""
|
|
48
|
+
out_path = Path(path) if path is not None else Path.cwd()
|
|
49
|
+
out_path.mkdir(exist_ok=True, parents=True)
|
|
50
|
+
data = {"project_name": out_path.name}
|
|
51
|
+
_init_directory(port._template_path, out_path, data=data)
|
|
52
|
+
write_metadata(out_path)
|
|
53
|
+
return out_path
|
port/meta.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Helpers for port project metadata."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
import port
|
|
7
|
+
from port.utils import ensure_spf_project, path_or_cwd
|
|
8
|
+
|
|
9
|
+
METADATA_DIRECTORY_NAME = ".port"
|
|
10
|
+
METADATA_FILE_NAME = "spf_version.txt"
|
|
11
|
+
SPF_VERSION_FILE_NAME = METADATA_FILE_NAME
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_version(spf_path: Path) -> None:
|
|
15
|
+
"""Write the current port version metadata file."""
|
|
16
|
+
with (spf_path / METADATA_FILE_NAME).open("w") as fi:
|
|
17
|
+
fi.write(port.__version__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_version(spf_path: Path) -> str:
|
|
21
|
+
"""Read the port version metadata value."""
|
|
22
|
+
with (spf_path / METADATA_FILE_NAME).open("r") as fi:
|
|
23
|
+
out = fi.read().rstrip()
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_metadata(project_path: Union[str, Path]) -> None:
|
|
28
|
+
"""Write project metadata for an initialized port project."""
|
|
29
|
+
spf_path = Path(project_path) / METADATA_DIRECTORY_NAME
|
|
30
|
+
spf_path.mkdir(exist_ok=True, parents=True)
|
|
31
|
+
_write_version(spf_path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_metadata(project_path: Union[str, Path, None]) -> dict[str, str]:
|
|
35
|
+
"""Read metadata from an initialized port project."""
|
|
36
|
+
path = path_or_cwd(project_path)
|
|
37
|
+
ensure_spf_project(path)
|
|
38
|
+
spf_path = path / METADATA_DIRECTORY_NAME
|
|
39
|
+
out = {
|
|
40
|
+
"spf_version": _read_version(spf_path),
|
|
41
|
+
}
|
|
42
|
+
return out
|
port/project.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read/write support for project structure.
|
|
3
|
+
"""
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
def _get_mtimes(files_or_folders) -> tuple[list[float], list[float]]:
|
|
10
|
+
"""
|
|
11
|
+
Get timestamps for files or folders.
|
|
12
|
+
If a folder, find the most recent mtime, including files and folders inside.
|
|
13
|
+
"""
|
|
14
|
+
def _get_min_max(entry):
|
|
15
|
+
path = Path(entry)
|
|
16
|
+
if path.is_file():
|
|
17
|
+
return (entry.stat().st_mtime, entry.stat().st_mtime)
|
|
18
|
+
# Go through the contents of the directory; return the min/max modified times.
|
|
19
|
+
time = path.stat().st_mtime
|
|
20
|
+
min_time, max_time = time, time
|
|
21
|
+
# iterate through contents, update min/max times.
|
|
22
|
+
for sub in path.rglob('*'):
|
|
23
|
+
subtime = sub.stat().st_mtime
|
|
24
|
+
if subtime < min_time:
|
|
25
|
+
min_time = subtime
|
|
26
|
+
if subtime > max_time:
|
|
27
|
+
max_time = subtime
|
|
28
|
+
return min_time, max_time
|
|
29
|
+
|
|
30
|
+
min_times, max_times = [], []
|
|
31
|
+
for entry in files_or_folders:
|
|
32
|
+
min_time, max_time = _get_min_max(entry)
|
|
33
|
+
min_times.append(min_time)
|
|
34
|
+
max_times.append(max_time)
|
|
35
|
+
return min_times, max_times
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Project(dataclass):
|
|
42
|
+
"""A class to encapsulate information about a project."""
|
|
43
|
+
# A dict that contains the direct dependencies from one ID to another.
|
|
44
|
+
dependencies: dict[str, tuple[str, ...]]
|
|
45
|
+
# A mapping from an ID to the path (relative to project directory)
|
|
46
|
+
script_id_path_map: dict[str, Path]
|
|
47
|
+
# Output paths
|
|
48
|
+
out_id_path_map: dict[str, Path]
|
|
49
|
+
# Outputs that are directories rather than files
|
|
50
|
+
directory_ids: set[str]
|
|
51
|
+
# Time stamps of scripts/outputs (key is path not id)
|
|
52
|
+
time_stamps: dict[str, float]
|
|
53
|
+
# Run command
|
|
54
|
+
backend: str = "uv"
|
|
55
|
+
_toml_name = "port.toml"
|
|
56
|
+
|
|
57
|
+
def write_toml(self, path):
|
|
58
|
+
"""Write the toml file containing information about the project."""
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_path(cls, path):
|
|
62
|
+
"""Create a project object from the path to a port project."""
|
|
63
|
+
# First, read existing toml, there is one, and get the back_end
|
|
64
|
+
outputs = {}
|
|
65
|
+
if (tomal_path := path / cls._toml_name).exists():
|
|
66
|
+
tom = tomllib.load(tomal_path)
|
|
67
|
+
outputs['backend'] = tom['backend']
|
|
68
|
+
# Next actually parse structure.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_dependencies():
|
|
73
|
+
"""Read the script dependencies, parse into a dictionary."""
|
port/utils/__init__.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for port.
|
|
3
|
+
"""
|
|
4
|
+
import contextlib
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Union
|
|
11
|
+
|
|
12
|
+
from port.exceptions import SPFInitError
|
|
13
|
+
|
|
14
|
+
# def get_task_list(project_path):
|
|
15
|
+
# """
|
|
16
|
+
# Return a list of pre-DoIt style task dicts.
|
|
17
|
+
#
|
|
18
|
+
# The dicts have the following keys:
|
|
19
|
+
# script_path - The path to the script relative to the project path.
|
|
20
|
+
# basename - The Name of the task
|
|
21
|
+
# inputs - A list of file inputs
|
|
22
|
+
# outputs - A list of file outputs
|
|
23
|
+
# """
|
|
24
|
+
# path = path_or_cwd(project_path)
|
|
25
|
+
# nodes = get_project_nodes(path)
|
|
26
|
+
# out = []
|
|
27
|
+
# for item, value in nodes.items():
|
|
28
|
+
# name = Path(item).name.split('_')[0]
|
|
29
|
+
# task = {
|
|
30
|
+
# 'basename': name,
|
|
31
|
+
# 'script_path': str(item),
|
|
32
|
+
# 'inputs': [str(x) for x in value['inputs']],
|
|
33
|
+
# 'outputs': [str(x) for x in value['outputs']],
|
|
34
|
+
# }
|
|
35
|
+
# out.append(task)
|
|
36
|
+
# return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_python_scripts(project_path) -> tuple[Path]:
|
|
40
|
+
"""
|
|
41
|
+
Return a tuple of python scripts which *may* produce outputs.
|
|
42
|
+
|
|
43
|
+
Returned paths are relative to project path.
|
|
44
|
+
"""
|
|
45
|
+
path = path_or_cwd(project_path)
|
|
46
|
+
out = []
|
|
47
|
+
for pypath in path.glob("*.py"):
|
|
48
|
+
name = pypath.name.split(".py")[0]
|
|
49
|
+
if len(name) < 4:
|
|
50
|
+
continue
|
|
51
|
+
letter = name[0]
|
|
52
|
+
numbers = name[1:4]
|
|
53
|
+
if letter.isalpha() and numbers.isnumeric():
|
|
54
|
+
out.append(pypath.relative_to(path))
|
|
55
|
+
return tuple(out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_task_dict_from_script(path, project_path):
|
|
59
|
+
"""Get the inputs and outputs for a python script."""
|
|
60
|
+
local_contents = get_local_contents(project_path)
|
|
61
|
+
script_str = (project_path / path).read_text()
|
|
62
|
+
script_lines = script_str.split("\n")
|
|
63
|
+
prefix = path.name.split("_")[0]
|
|
64
|
+
nodes = []
|
|
65
|
+
for name, value in local_contents.items():
|
|
66
|
+
if name not in script_str:
|
|
67
|
+
continue
|
|
68
|
+
# simple case where variable is used as local.{name}
|
|
69
|
+
if f"local.{name}" not in script_str:
|
|
70
|
+
# now need to check from local import name style
|
|
71
|
+
match = f"*from local import *{name}*"
|
|
72
|
+
has_from_input = [fnmatch.fnmatch(x, match) for x in script_lines]
|
|
73
|
+
# we only get here if an unrelated local variable shares a name
|
|
74
|
+
# with a project variable
|
|
75
|
+
if not any(has_from_input):
|
|
76
|
+
continue
|
|
77
|
+
nodes.append(name)
|
|
78
|
+
|
|
79
|
+
is_output = {x: local_contents[x].name.startswith(prefix) for x in nodes}
|
|
80
|
+
outputs = [str(local_contents[x]) for x in nodes if is_output[x]]
|
|
81
|
+
inputs = [str(local_contents[x]) for x in nodes if not is_output[x]]
|
|
82
|
+
out = dict(
|
|
83
|
+
file_outputs=tuple(outputs),
|
|
84
|
+
file_inputs=tuple(inputs),
|
|
85
|
+
name=Path(path).name.split("_")[0],
|
|
86
|
+
script_path=path,
|
|
87
|
+
)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_project_task_list(project_path):
|
|
92
|
+
"""Get the inputs and outputs for each script in the project."""
|
|
93
|
+
project_path = path_or_cwd(project_path)
|
|
94
|
+
scripts = get_python_scripts(project_path)
|
|
95
|
+
return {x: _get_task_dict_from_script(x, project_path) for x in scripts}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_local_contents(project_path) -> dict:
|
|
99
|
+
"""Get a dict of variable_name: path for each input/output path"""
|
|
100
|
+
|
|
101
|
+
def _get_artifacts(local):
|
|
102
|
+
"""extract all potential inputs/outputs from local."""
|
|
103
|
+
info = {}
|
|
104
|
+
input_str = str(input_path)
|
|
105
|
+
output_str = str(output_path)
|
|
106
|
+
for name in dir(local):
|
|
107
|
+
# skip any dunders and non-paths
|
|
108
|
+
if name.startswith("__"):
|
|
109
|
+
continue
|
|
110
|
+
var = getattr(local, name)
|
|
111
|
+
if not isinstance(var, (str, Path)):
|
|
112
|
+
continue
|
|
113
|
+
path_str = str(Path(var))
|
|
114
|
+
in_inputs = path_str.startswith(input_str) and path_str != input_str
|
|
115
|
+
in_outputs = path_str.startswith(output_str) and path_str != output_str
|
|
116
|
+
if in_inputs or in_outputs:
|
|
117
|
+
info[name] = Path(var).relative_to(project_path)
|
|
118
|
+
return info
|
|
119
|
+
|
|
120
|
+
# get path to local.py
|
|
121
|
+
path = path_or_cwd(project_path).absolute()
|
|
122
|
+
if not path.name.endswith("local.py"):
|
|
123
|
+
path = project_path / "local.py"
|
|
124
|
+
else:
|
|
125
|
+
project_path = path.parent
|
|
126
|
+
# load module
|
|
127
|
+
with working_directory(path.parent): # chdir to local
|
|
128
|
+
spec = importlib.util.spec_from_file_location("local", str(path))
|
|
129
|
+
local = importlib.util.module_from_spec(spec)
|
|
130
|
+
spec.loader.exec_module(local)
|
|
131
|
+
input_path, output_path = local.input_path, local.output_path
|
|
132
|
+
return _get_artifacts(local)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def path_or_cwd(path: Optional[Union[str, Path]] = None) -> Path:
|
|
136
|
+
"""Return a Path from path or, if None, return Path of cwd."""
|
|
137
|
+
if path is None:
|
|
138
|
+
path = Path.cwd()
|
|
139
|
+
return Path(path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def ensure_spf_project(project_path: Union[str, Path]) -> Path:
|
|
143
|
+
"""
|
|
144
|
+
Ensure the path is a port project by checking for port metadata.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
project_path
|
|
149
|
+
Path to project.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
path = path_or_cwd(project_path)
|
|
156
|
+
if not path.exists():
|
|
157
|
+
msg = (
|
|
158
|
+
f"{path.parent} is not yet an " f"port project. Try running `port init`."
|
|
159
|
+
)
|
|
160
|
+
raise SPFInitError(msg)
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@contextlib.contextmanager
|
|
165
|
+
def working_directory(path):
|
|
166
|
+
"""Changes working directory and returns to previous on exit."""
|
|
167
|
+
prev_cwd = Path.cwd()
|
|
168
|
+
os.chdir(path)
|
|
169
|
+
try:
|
|
170
|
+
yield
|
|
171
|
+
finally:
|
|
172
|
+
os.chdir(prev_cwd)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def file_hash(fname: Union[str, Path], algorithm="sha256") -> str:
|
|
176
|
+
"""
|
|
177
|
+
Calculate the hash of a given file.
|
|
178
|
+
|
|
179
|
+
Useful for checking if a file has changed or been corrupted.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
fname : str
|
|
184
|
+
The name of the file.
|
|
185
|
+
algorithm : str
|
|
186
|
+
The type of the hashing algorithm
|
|
187
|
+
|
|
188
|
+
Examples
|
|
189
|
+
--------
|
|
190
|
+
>>> fname = "test-file-for-hash.txt"
|
|
191
|
+
>>> with open(fname, "w") as f:
|
|
192
|
+
... __ = f.write("content of the file")
|
|
193
|
+
>>> print(file_hash(fname))
|
|
194
|
+
0fc74468e6a9a829f103d069aeb2bb4f8646bad58bf146bb0e3379b759ec4a00
|
|
195
|
+
>>> import os
|
|
196
|
+
>>> os.remove(fname)
|
|
197
|
+
|
|
198
|
+
Notes
|
|
199
|
+
-----
|
|
200
|
+
This implementation was taken from the excellent Pooch project:
|
|
201
|
+
https://github.com/fatiando/pooch
|
|
202
|
+
"""
|
|
203
|
+
if algorithm not in hashlib.algorithms_available:
|
|
204
|
+
raise ValueError(f"Algorithm '{algorithm}' not available in hashlib")
|
|
205
|
+
# Calculate the hash in chunks to avoid overloading the memory
|
|
206
|
+
chunksize = 65536
|
|
207
|
+
hasher = hashlib.new(algorithm)
|
|
208
|
+
with open(fname, "rb") as fin:
|
|
209
|
+
buff = fin.read(chunksize)
|
|
210
|
+
while buff:
|
|
211
|
+
hasher.update(buff)
|
|
212
|
+
buff = fin.read(chunksize)
|
|
213
|
+
return hasher.hexdigest()
|
port/utils/dag.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for working with the DAG
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def topological_sort(dependencies: dict[str, list[str]]) -> list[str]:
|
|
9
|
+
"""
|
|
10
|
+
Return tasks in topological order for execution using Kahn's algorithm.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
dependencies
|
|
15
|
+
Dictionary mapping task_id -> [dependent_task_id, ...].
|
|
16
|
+
|
|
17
|
+
Returns
|
|
18
|
+
-------
|
|
19
|
+
List[str]
|
|
20
|
+
Task IDs in execution order.
|
|
21
|
+
|
|
22
|
+
Notes
|
|
23
|
+
-----
|
|
24
|
+
- (Kahn's algorithm)[https://en.wikipedia.org/wiki/Topological_sorting]
|
|
25
|
+
|
|
26
|
+
Raises
|
|
27
|
+
------
|
|
28
|
+
ValueError
|
|
29
|
+
If circular dependency is detected in the task graph.
|
|
30
|
+
"""
|
|
31
|
+
all_task_ids = set(dependencies.keys())
|
|
32
|
+
for task_dependents in dependencies.values():
|
|
33
|
+
all_task_ids.update(task_dependents)
|
|
34
|
+
|
|
35
|
+
# Kahn's algorithm for topological sorting
|
|
36
|
+
in_degree = {task_id: 0 for task_id in all_task_ids}
|
|
37
|
+
adjacency: dict[str, list[str]] = {task_id: [] for task_id in all_task_ids}
|
|
38
|
+
|
|
39
|
+
for task_id, task_dependents in dependencies.items():
|
|
40
|
+
for dependent_id in task_dependents:
|
|
41
|
+
adjacency[task_id].append(dependent_id)
|
|
42
|
+
in_degree[dependent_id] += 1
|
|
43
|
+
|
|
44
|
+
# Start with tasks that have no dependencies
|
|
45
|
+
queue = deque(task_id for task_id, degree in in_degree.items() if degree == 0)
|
|
46
|
+
result = []
|
|
47
|
+
|
|
48
|
+
while queue:
|
|
49
|
+
current = queue.popleft()
|
|
50
|
+
result.append(current)
|
|
51
|
+
|
|
52
|
+
# Reduce in-degree for dependent tasks
|
|
53
|
+
for downstream_id in adjacency[current]:
|
|
54
|
+
in_degree[downstream_id] -= 1
|
|
55
|
+
if in_degree[downstream_id] == 0:
|
|
56
|
+
queue.append(downstream_id)
|
|
57
|
+
|
|
58
|
+
if len(result) != len(all_task_ids):
|
|
59
|
+
raise ValueError("Circular dependency detected in pipe")
|
|
60
|
+
|
|
61
|
+
return result
|
port/utils/parse.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for parsing files dependencies.
|
|
3
|
+
"""
|
|
4
|
+
import ast
|
|
5
|
+
import importlib.util
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
from port.constants import local_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_module_from_path(path: Path) -> ModuleType:
|
|
15
|
+
"""Load and return a module object from a python file path."""
|
|
16
|
+
spec = importlib.util.spec_from_file_location("local", str(path))
|
|
17
|
+
if spec is None or spec.loader is None:
|
|
18
|
+
raise ValueError(f"Could not load module from '{path}'")
|
|
19
|
+
module = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(module)
|
|
21
|
+
return module
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_local(path: Path) -> dict[str, Path]:
|
|
25
|
+
"""
|
|
26
|
+
Parse path variables from a project's ``local.py`` module.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
path
|
|
31
|
+
Either the path to ``local.py`` or the project directory that
|
|
32
|
+
contains ``local.py``.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
dict[str, Path]
|
|
37
|
+
Mapping of variable name to ``Path`` value for top-level variables
|
|
38
|
+
in ``local.py`` that are ``Path`` instances.
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
FileNotFoundError
|
|
43
|
+
If ``local.py`` cannot be found.
|
|
44
|
+
ValueError
|
|
45
|
+
If a ``local.py`` module cannot be loaded from the given path.
|
|
46
|
+
"""
|
|
47
|
+
local_path = Path(path)
|
|
48
|
+
if local_path.is_dir():
|
|
49
|
+
local_path = local_path / "local.py"
|
|
50
|
+
if not local_path.exists():
|
|
51
|
+
raise FileNotFoundError(f"Cannot find local.py at '{local_path}'")
|
|
52
|
+
|
|
53
|
+
local_module = _load_module_from_path(local_path)
|
|
54
|
+
out: dict[str, Path] = {}
|
|
55
|
+
for name in dir(local_module):
|
|
56
|
+
if name.startswith("_"):
|
|
57
|
+
continue
|
|
58
|
+
value = getattr(local_module, name)
|
|
59
|
+
if isinstance(value, Path):
|
|
60
|
+
out[name] = value
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_script(path: Path, param_dict: Mapping[str, Path]) -> dict[str, set[str]]:
|
|
65
|
+
"""
|
|
66
|
+
Parse script usage of ``local`` variables into inputs and outputs.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
path
|
|
71
|
+
Path to the script file to parse.
|
|
72
|
+
param_dict
|
|
73
|
+
Mapping of ``local`` variable name to path, typically from
|
|
74
|
+
:func:`parse_local`.
|
|
75
|
+
|
|
76
|
+
Notes
|
|
77
|
+
-----
|
|
78
|
+
``param_dict`` is of the form ``{variable_name: path}``.
|
|
79
|
+
Local variables are detected for these import styles:
|
|
80
|
+
- ``import local`` / ``import local as x``
|
|
81
|
+
- ``from local import var_name`` (with alias support)
|
|
82
|
+
- ``from local import *`` (best-effort static detection)
|
|
83
|
+
- variables that share the same ID (or the path shares the same id)
|
|
84
|
+
as the script are considered outputs. Otherwise, they are inputs.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
dict[str, set[str]]
|
|
89
|
+
Dictionary with keys ``inputs`` and ``outputs`` containing sets of
|
|
90
|
+
variable names used by the script. Output variables are those whose
|
|
91
|
+
variable name or artifact file name starts with the script prefix
|
|
92
|
+
(for example ``a010``).
|
|
93
|
+
"""
|
|
94
|
+
script_path = Path(path)
|
|
95
|
+
tree = ast.parse(script_path.read_text())
|
|
96
|
+
local_names = set(param_dict)
|
|
97
|
+
script_prefix = script_path.name.split("_")[0]
|
|
98
|
+
|
|
99
|
+
module_aliases, direct_imports, has_star_import = _collect_local_imports(tree)
|
|
100
|
+
bound_names = _collect_bound_names(tree)
|
|
101
|
+
used_names = _collect_local_usages(
|
|
102
|
+
tree=tree,
|
|
103
|
+
local_names=local_names,
|
|
104
|
+
module_aliases=module_aliases,
|
|
105
|
+
direct_imports=direct_imports,
|
|
106
|
+
has_star_import=has_star_import,
|
|
107
|
+
bound_names=bound_names,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
inputs: set[str] = set()
|
|
111
|
+
outputs: set[str] = set()
|
|
112
|
+
for name in used_names:
|
|
113
|
+
file_name = Path(param_dict[name]).name
|
|
114
|
+
is_output_name = name.startswith(script_prefix)
|
|
115
|
+
is_output_path = file_name.startswith(script_prefix)
|
|
116
|
+
if is_output_name or is_output_path:
|
|
117
|
+
outputs.add(name)
|
|
118
|
+
else:
|
|
119
|
+
inputs.add(name)
|
|
120
|
+
return {"inputs": inputs, "outputs": outputs}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _collect_local_imports(
|
|
124
|
+
tree: ast.AST,
|
|
125
|
+
) -> tuple[set[str], dict[str, str], bool]:
|
|
126
|
+
"""Collect local module aliases, direct imports, and wildcard imports."""
|
|
127
|
+
module_aliases: set[str] = set()
|
|
128
|
+
direct_imports: dict[str, str] = {}
|
|
129
|
+
has_star_import = False
|
|
130
|
+
|
|
131
|
+
for node in ast.walk(tree):
|
|
132
|
+
if isinstance(node, ast.Import):
|
|
133
|
+
for alias in node.names:
|
|
134
|
+
if alias.name == "local":
|
|
135
|
+
module_aliases.add(alias.asname or "local")
|
|
136
|
+
elif isinstance(node, ast.ImportFrom) and node.module == "local":
|
|
137
|
+
for alias in node.names:
|
|
138
|
+
if alias.name == "*":
|
|
139
|
+
has_star_import = True
|
|
140
|
+
else:
|
|
141
|
+
direct_imports[alias.asname or alias.name] = alias.name
|
|
142
|
+
return module_aliases, direct_imports, has_star_import
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _collect_bound_names(tree: ast.AST) -> set[str]:
|
|
146
|
+
"""Collect names that are locally bound in the script."""
|
|
147
|
+
out: set[str] = set()
|
|
148
|
+
for node in ast.walk(tree):
|
|
149
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
150
|
+
out.add(node.id)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _collect_local_usages(
|
|
155
|
+
tree: ast.AST,
|
|
156
|
+
local_names: set[str],
|
|
157
|
+
module_aliases: set[str],
|
|
158
|
+
direct_imports: dict[str, str],
|
|
159
|
+
has_star_import: bool,
|
|
160
|
+
bound_names: set[str],
|
|
161
|
+
) -> set[str]:
|
|
162
|
+
"""Collect local variable names used in script."""
|
|
163
|
+
used_names: set[str] = set()
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
|
|
166
|
+
if node.value.id in module_aliases and node.attr in local_names:
|
|
167
|
+
used_names.add(node.attr)
|
|
168
|
+
elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
|
169
|
+
if node.id in direct_imports:
|
|
170
|
+
if direct_imports[node.id] in local_names:
|
|
171
|
+
used_names.add(direct_imports[node.id])
|
|
172
|
+
elif (
|
|
173
|
+
has_star_import
|
|
174
|
+
and node.id in local_names
|
|
175
|
+
and node.id not in bound_names
|
|
176
|
+
):
|
|
177
|
+
used_names.add(node.id)
|
|
178
|
+
return used_names
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_dependency_graph(
|
|
182
|
+
project_path: Path,
|
|
183
|
+
) -> dict[str, list[str]]:
|
|
184
|
+
"""
|
|
185
|
+
Build a script dependency graph for a project.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
project_path
|
|
190
|
+
Path to the project directory.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
dict[str, list[str]]
|
|
195
|
+
Mapping of ``{task_id: [dependent_task_id, ...]}`` where each key is
|
|
196
|
+
a script id and each value lists downstream scripts that depend on it.
|
|
197
|
+
"""
|
|
198
|
+
project_path = Path(project_path)
|
|
199
|
+
local_vars = parse_local(project_path / local_path)
|
|
200
|
+
|
|
201
|
+
# Collect parsed local-variable usage for each task script.
|
|
202
|
+
script_vars: dict[str, dict[str, set[str]]] = {}
|
|
203
|
+
for script_path in sorted(project_path.glob("*.py")):
|
|
204
|
+
task_id = _get_task_id(script_path)
|
|
205
|
+
if task_id is None:
|
|
206
|
+
continue
|
|
207
|
+
script_vars[task_id] = parse_script(script_path, local_vars)
|
|
208
|
+
|
|
209
|
+
# Map output variable name -> producing task id.
|
|
210
|
+
producers: dict[str, set[str]] = defaultdict(set)
|
|
211
|
+
for task_id, var_map in script_vars.items():
|
|
212
|
+
for output_var in var_map["outputs"]:
|
|
213
|
+
producers[output_var].add(task_id)
|
|
214
|
+
|
|
215
|
+
# Build adjacency: upstream task -> dependent downstream tasks.
|
|
216
|
+
dependencies: dict[str, set[str]] = {task_id: set() for task_id in script_vars}
|
|
217
|
+
for task_id, var_map in script_vars.items():
|
|
218
|
+
upstream_ids = {
|
|
219
|
+
producer_id
|
|
220
|
+
for input_var in var_map["inputs"]
|
|
221
|
+
for producer_id in producers.get(input_var, set())
|
|
222
|
+
if producer_id != task_id
|
|
223
|
+
}
|
|
224
|
+
for upstream_id in upstream_ids:
|
|
225
|
+
dependencies[upstream_id].add(task_id)
|
|
226
|
+
|
|
227
|
+
return {task_id: sorted(dependents) for task_id, dependents in dependencies.items()}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _get_task_id(script_path: Path) -> str | None:
|
|
231
|
+
"""Return task id from script name if it matches the task pattern."""
|
|
232
|
+
stem = script_path.stem
|
|
233
|
+
if len(stem) < 4:
|
|
234
|
+
return None
|
|
235
|
+
if not stem[0].isalpha() or not stem[1:4].isdigit():
|
|
236
|
+
return None
|
|
237
|
+
return stem.split("_")[0]
|
port/version.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stooge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple project framework to keep you from getting burned
|
|
5
|
+
Author-email: Derrick Chambers <chambers.ja.derrick@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2023 Derrick Chambers
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Bug Tracker, https://github.com/d-chambers/port/issues
|
|
29
|
+
Project-URL: Documentation, https://github.com/d-chambers/port
|
|
30
|
+
Project-URL: Homepage, https://github.com/d-chambers/port
|
|
31
|
+
Keywords: workflow,projects
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
36
|
+
Classifier: Topic :: Scientific/Engineering
|
|
37
|
+
Requires-Python: >=3.13
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: typer
|
|
41
|
+
Requires-Dist: rich
|
|
42
|
+
Requires-Dist: jinja2
|
|
43
|
+
Requires-Dist: toml
|
|
44
|
+
Requires-Dist: pdbpp>=0.12.0.post1
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest; extra == "test"
|
|
47
|
+
Requires-Dist: pre-commit; extra == "test"
|
|
48
|
+
Requires-Dist: ruff; extra == "test"
|
|
49
|
+
Requires-Dist: scikit-learn; extra == "test"
|
|
50
|
+
Provides-Extra: dev
|
|
51
|
+
Requires-Dist: stooge[test]; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
# port
|
|
55
|
+
|
|
56
|
+
A simple project framework.
|
|
57
|
+
|
|
58
|
+
`port` facilitates rapid, reproducible development for local research projects.
|
|
59
|
+
|
|
60
|
+
## Project structure
|
|
61
|
+
|
|
62
|
+
port requires a specific project structure.
|
|
63
|
+
|
|
64
|
+
An example project directory looks like this:
|
|
65
|
+
|
|
66
|
+
my_project
|
|
67
|
+
-> inputs/
|
|
68
|
+
---> some_file.csv
|
|
69
|
+
-> outputs/
|
|
70
|
+
-> local.py
|
|
71
|
+
-> environment.yml
|
|
72
|
+
-> a010_clean_data.py
|
|
73
|
+
-> a020_calculate.py
|
|
74
|
+
-> a030_visualize.py
|
|
75
|
+
|
|
76
|
+
Each script has an ID (the part of the name before the first `_`).
|
|
77
|
+
|
|
78
|
+
The file called `local.py` defines the inputs and outputs of the scripts. In this file, outputs are also given the same prefix.
|
|
79
|
+
|
|
80
|
+
For example:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
|
|
85
|
+
input_path = Path("inputs")
|
|
86
|
+
output_path = Path("outputs")
|
|
87
|
+
|
|
88
|
+
raw_data_path = input_path / "raw data.csv"
|
|
89
|
+
|
|
90
|
+
a010_clean_data_path = output_path / "a010_cleaned_outputs.csv"
|
|
91
|
+
a020_calculated_data_path = output_path / "a020_calculated_results.csv"
|
|
92
|
+
a030_result_plot = output_path / "a030_result_plot.png"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This provides port with all the required information to figure out the relationships between scripts and their outputs.
|
|
96
|
+
|
|
97
|
+
## Init
|
|
98
|
+
Creates a port project skeleton.
|
|
99
|
+
|
|
100
|
+
This provides a small Rich interface to get information about the project name and backend, or they can be provided via command-line flags.
|
|
101
|
+
|
|
102
|
+
Supported backends are:
|
|
103
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
104
|
+
- [miniforge](https://github.com/conda-forge/miniforge)
|
|
105
|
+
- [marimo](https://marimo.io/)
|
|
106
|
+
- python (use system python or provided interpreter path)
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
port init test_project --backend uv
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Run
|
|
113
|
+
|
|
114
|
+
`port run` runs the entire project up to and including a target ID. It automatically calculates stale or missing results that `a030` depends on.
|
|
115
|
+
|
|
116
|
+
For example:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
port run a030
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This first gets the directed acyclic graph (DAG) that defines `a030`'s dependencies. It then uses file output mtime values to determine if any results need to be re-run. This includes:
|
|
123
|
+
|
|
124
|
+
- Requiring that, for a given output, all dependency outputs have an mtime less than or equal to the current output.
|
|
125
|
+
- Requiring all outputs to have an mtime greater than or equal to the script that creates them.
|
|
126
|
+
|
|
127
|
+
Flags include:
|
|
128
|
+
- `--force`: rerun the specified task.
|
|
129
|
+
- `--force-all`: rerun all affected tasks.
|
|
130
|
+
- `--debug`: If true, drop into a pdb debugger on failure.
|
|
131
|
+
|
|
132
|
+
## Remove
|
|
133
|
+
|
|
134
|
+
Remove results from specified tasks.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
port remove a020 # removes the results for a020
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Parse
|
|
141
|
+
|
|
142
|
+
Although typically called automatically by other commands, it can be used manually to parse script structure/dependencies and update timestamps.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
port parse # parse current directory, or pass a path
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The flag `--dryrun` can be used to simply validate the project structure.
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Guiding principles
|
|
152
|
+
- `port` is always optional. A project can be fully executed by running the scripts in order. It is there when you want it, and it gets out of your way when you don't.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
port/__init__.py,sha256=8aFefuNlnxNHNFB2DaomxAwVDpWwe71bqRZ3k_mPhGk,277
|
|
2
|
+
port/cli.py,sha256=ZJRJFR8ltjqE7plAZ6YE_cRQ70Hdp-9xNIZWQCBQHkE,940
|
|
3
|
+
port/console.py,sha256=3d7beBMU-_gvTk8ln-PvFZBfPgLZ1LVvZU34LRgruNw,98
|
|
4
|
+
port/constants.py,sha256=DgkFhtbCY37xY0MJJaJ6tmiSS6oSYLJXpDQq3rAuPgk,117
|
|
5
|
+
port/exceptions.py,sha256=v9xp_Z1_FR7bp-reGLvQ7RXYiEbmjyJSOF8JoKpFT3c,203
|
|
6
|
+
port/info.py,sha256=dfeahttF3hmebcp28soh5e96QhWjTh1OWCsP32_vuUk,530
|
|
7
|
+
port/init.py,sha256=PujSbES20iSLIocTy41xrYV9bn1VQHrrf66WZbVtUC4,1702
|
|
8
|
+
port/meta.py,sha256=0E4ARTrdB2JYcrQp5r-0wrkarsvfPIgbAcO9UBP_s5Q,1263
|
|
9
|
+
port/project.py,sha256=7bU6mNqxx2UY6nqptJi_L_2m6w4wmIW0ebpQQKPT0U0,2419
|
|
10
|
+
port/version.py,sha256=NdlMsj9y8ABZ7HNYtSyMs1xJRrPl3ct8iyfEod7AQ98,44
|
|
11
|
+
port/data/project_template/a010_first_script.py,sha256=NE-kIzAWS3RPoBQy1M9t-rXKbnZ0plfxROekdZEe0D0,102
|
|
12
|
+
port/data/project_template/local.py,sha256=t_AIfqPYNBaU57FzYO1ofYnpCEcq-zp26N1YmXLg-3s,906
|
|
13
|
+
port/data/project_template/utils.py,sha256=jtbycQRnTej-AEKJ0mT3rfI3lNpP6Ec0JpQfoZupQ_A,42
|
|
14
|
+
port/data/spf_example/a010_clean_data.py,sha256=BlTcnd14WorP5Up3RjxFfQHMa_pB5J0XSG66tMvp5Qs,553
|
|
15
|
+
port/data/spf_example/a020_calculate.py,sha256=BYkwaMPx55NmkrzgrBezlj0hzrzPdgPC3nSyLYtkA-8,640
|
|
16
|
+
port/data/spf_example/a030_visualize.py,sha256=Mj4uQlvTCYYd8HWIODOjfLCl3ACNFSXzH3VCqhS328o,1000
|
|
17
|
+
port/data/spf_example/local.py,sha256=TA8cUpibzjU7Gvje3BGXCkjV0tIE5zGCuG2ZunfWUYo,1023
|
|
18
|
+
port/data/spf_example/utils.py,sha256=ERlqLwiApPxuvcWLhmPKkhfQI8A-PPlk-r3TtfTXXow,423
|
|
19
|
+
port/utils/__init__.py,sha256=YFiryBF0727EF1vODxTfhmTf1f2g-7RIY_6X_YupMWQ,6697
|
|
20
|
+
port/utils/dag.py,sha256=UACH7q6wPjGU4Gl8ahtwmW-XZk-g0o4aMRTh3G8fU6o,1732
|
|
21
|
+
port/utils/parse.py,sha256=lTGf_tLojhTM2Ox29B5C1_10G35P0eJMbyzeV8KWZgA,7990
|
|
22
|
+
stooge-0.1.0.dist-info/licenses/LICENSE,sha256=94pKobixYnFwavNd6FYQOOPPUgfpV2l7QmhyolWrm3Q,1073
|
|
23
|
+
stooge-0.1.0.dist-info/METADATA,sha256=qhM9Tn1xYSsLqz1icIdhTgymwL96Y-dBREC-Jm-RnU8,5203
|
|
24
|
+
stooge-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
25
|
+
stooge-0.1.0.dist-info/entry_points.txt,sha256=RTqTNzAw7Xo4juD-K3RxKtbFqrQFQS-nxz8hYdnTDso,38
|
|
26
|
+
stooge-0.1.0.dist-info/top_level.txt,sha256=T2UTGKEM-1RD_6VLq7wiTrhY49fKyh8rqgUHarvGSYQ,5
|
|
27
|
+
stooge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Derrick Chambers
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
port
|