tangra-dataforge 0.1.0a1__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.
- tangra_dataforge/__init__.py +0 -0
- tangra_dataforge/analyze.py +83 -0
- tangra_dataforge/browser.py +0 -0
- tangra_dataforge/clean.py +13 -0
- tangra_dataforge/collect.py +66 -0
- tangra_dataforge/database.py +48 -0
- tangra_dataforge/geospatial.py +0 -0
- tangra_dataforge/interactive.py +77 -0
- tangra_dataforge/pipeline.py +121 -0
- tangra_dataforge/provenance.py +53 -0
- tangra_dataforge/scrape.py +90 -0
- tangra_dataforge/statistics.py +158 -0
- tangra_dataforge/validate.py +128 -0
- tangra_dataforge/visualize.py +90 -0
- tangra_dataforge-0.1.0a1.dist-info/METADATA +131 -0
- tangra_dataforge-0.1.0a1.dist-info/RECORD +18 -0
- tangra_dataforge-0.1.0a1.dist-info/WHEEL +4 -0
- tangra_dataforge-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
|
|
2
|
+
"""Statistical analysis utilities for TANGRA DataForge."""
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def describe_data(df: pd.DataFrame) -> pd.DataFrame:
|
|
8
|
+
"""Calculate descriptive statistics for numeric columns."""
|
|
9
|
+
return df.describe()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def calculate_percentage_change(
|
|
13
|
+
df: pd.DataFrame,
|
|
14
|
+
column: str,
|
|
15
|
+
periods: int = 1,
|
|
16
|
+
) -> pd.Series:
|
|
17
|
+
"""Calculate percentage change between observations."""
|
|
18
|
+
if column not in df.columns:
|
|
19
|
+
raise KeyError(f"Column not found: {column}")
|
|
20
|
+
|
|
21
|
+
if periods < 1:
|
|
22
|
+
raise ValueError("periods must be at least 1")
|
|
23
|
+
|
|
24
|
+
return df[column].pct_change(periods=periods) * 100
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def calculate_growth_rate(
|
|
28
|
+
initial_value: float,
|
|
29
|
+
final_value: float,
|
|
30
|
+
) -> float:
|
|
31
|
+
"""Calculate growth between two values as a percentage."""
|
|
32
|
+
if initial_value == 0:
|
|
33
|
+
raise ValueError("Initial value cannot be zero.")
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
(final_value - initial_value)
|
|
37
|
+
/ abs(initial_value)
|
|
38
|
+
) * 100
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compare_columns(
|
|
42
|
+
df: pd.DataFrame,
|
|
43
|
+
column_a: str,
|
|
44
|
+
column_b: str,
|
|
45
|
+
) -> pd.DataFrame:
|
|
46
|
+
"""Compare two numeric columns using absolute differences."""
|
|
47
|
+
if column_a not in df.columns or column_b not in df.columns:
|
|
48
|
+
raise KeyError("One or both columns were not found.")
|
|
49
|
+
|
|
50
|
+
result = df[[column_a, column_b]].copy()
|
|
51
|
+
result["difference"] = result[column_a] - result[column_b]
|
|
52
|
+
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_outliers(
|
|
57
|
+
df: pd.DataFrame,
|
|
58
|
+
column: str,
|
|
59
|
+
threshold: float = 1.5,
|
|
60
|
+
) -> pd.DataFrame:
|
|
61
|
+
"""Detect outliers using the interquartile range (IQR)."""
|
|
62
|
+
if column not in df.columns:
|
|
63
|
+
raise KeyError(f"Column not found: {column}")
|
|
64
|
+
|
|
65
|
+
if threshold <= 0:
|
|
66
|
+
raise ValueError("threshold must be positive")
|
|
67
|
+
|
|
68
|
+
values = df[column].dropna()
|
|
69
|
+
|
|
70
|
+
if values.empty:
|
|
71
|
+
return df.iloc[0:0].copy()
|
|
72
|
+
|
|
73
|
+
q1 = values.quantile(0.25)
|
|
74
|
+
q3 = values.quantile(0.75)
|
|
75
|
+
iqr = q3 - q1
|
|
76
|
+
|
|
77
|
+
lower_bound = q1 - threshold * iqr
|
|
78
|
+
upper_bound = q3 + threshold * iqr
|
|
79
|
+
|
|
80
|
+
return df[
|
|
81
|
+
(df[column] < lower_bound)
|
|
82
|
+
| (df[column] > upper_bound)
|
|
83
|
+
].copy()
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Data cleaning utilities for TANGRA DataForge."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame:
|
|
7
|
+
"""Remove duplicate rows and reset the index."""
|
|
8
|
+
return df.drop_duplicates().reset_index(drop=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def remove_empty_rows(df: pd.DataFrame) -> pd.DataFrame:
|
|
12
|
+
"""Remove rows where all values are missing."""
|
|
13
|
+
return df.dropna(how="all").reset_index(drop=True)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Data collection utilities for TANGRA DataForge."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_csv(
|
|
10
|
+
path: str | Path,
|
|
11
|
+
**kwargs: Any,
|
|
12
|
+
) -> pd.DataFrame:
|
|
13
|
+
"""Load a CSV file into a Pandas DataFrame."""
|
|
14
|
+
return pd.read_csv(path, **kwargs)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_json(
|
|
18
|
+
path: str | Path,
|
|
19
|
+
**kwargs: Any,
|
|
20
|
+
) -> pd.DataFrame:
|
|
21
|
+
"""Load a JSON file into a Pandas DataFrame."""
|
|
22
|
+
return pd.read_json(path, **kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_excel(
|
|
26
|
+
path: str | Path,
|
|
27
|
+
sheet_name: str | int = 0,
|
|
28
|
+
**kwargs: Any,
|
|
29
|
+
) -> pd.DataFrame:
|
|
30
|
+
"""Load one Excel worksheet into a DataFrame."""
|
|
31
|
+
return pd.read_excel(
|
|
32
|
+
path,
|
|
33
|
+
sheet_name=sheet_name,
|
|
34
|
+
**kwargs,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fetch_api(
|
|
39
|
+
url: str,
|
|
40
|
+
*,
|
|
41
|
+
timeout: float = 15.0,
|
|
42
|
+
params: dict[str, Any] | None = None,
|
|
43
|
+
) -> pd.DataFrame:
|
|
44
|
+
"""Fetch a JSON array of records from a public API.
|
|
45
|
+
|
|
46
|
+
The endpoint must return a JSON list of objects.
|
|
47
|
+
"""
|
|
48
|
+
import requests
|
|
49
|
+
|
|
50
|
+
response = requests.get(
|
|
51
|
+
url,
|
|
52
|
+
params=params,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
|
|
57
|
+
data = response.json()
|
|
58
|
+
|
|
59
|
+
if not isinstance(data, list) or not all(
|
|
60
|
+
isinstance(item, dict) for item in data
|
|
61
|
+
):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"API response must be a JSON list of objects."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return pd.DataFrame(data)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
"""DuckDB integration for TANGRA DataForge.
|
|
4
|
+
|
|
5
|
+
Query Pandas DataFrames, CSV files, and Parquet datasets
|
|
6
|
+
using SQL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import duckdb
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def query_dataframe(
|
|
16
|
+
df: pd.DataFrame,
|
|
17
|
+
sql: str,
|
|
18
|
+
) -> pd.DataFrame:
|
|
19
|
+
"""Query a DataFrame registered as 'data'."""
|
|
20
|
+
with duckdb.connect(database=":memory:") as con:
|
|
21
|
+
con.register("data", df)
|
|
22
|
+
return con.execute(sql).fetchdf()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def query_csv(
|
|
26
|
+
path: str | Path,
|
|
27
|
+
sql: str,
|
|
28
|
+
) -> pd.DataFrame:
|
|
29
|
+
"""Query a CSV file registered as 'data'."""
|
|
30
|
+
path = Path(path)
|
|
31
|
+
|
|
32
|
+
with duckdb.connect(database=":memory:") as con:
|
|
33
|
+
data = con.read_csv(str(path))
|
|
34
|
+
con.register("data", data)
|
|
35
|
+
return con.execute(sql).fetchdf()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def query_parquet(
|
|
39
|
+
path: str | Path,
|
|
40
|
+
sql: str,
|
|
41
|
+
) -> pd.DataFrame:
|
|
42
|
+
"""Query a Parquet file registered as 'data'."""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
|
|
45
|
+
with duckdb.connect(database=":memory:") as con:
|
|
46
|
+
data = con.read_parquet(str(path))
|
|
47
|
+
con.register("data", data)
|
|
48
|
+
return con.execute(sql).fetchdf()
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
|
|
2
|
+
"""Interactive data visualization for TANGRA DataForge.
|
|
3
|
+
|
|
4
|
+
Create interactive Plotly charts for research,
|
|
5
|
+
data journalism, and web publishing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import plotly.express as px
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def interactive_line(
|
|
16
|
+
df: pd.DataFrame,
|
|
17
|
+
x: str,
|
|
18
|
+
y: str,
|
|
19
|
+
title: str = "",
|
|
20
|
+
) -> go.Figure:
|
|
21
|
+
"""Create an interactive line chart."""
|
|
22
|
+
fig = px.line(
|
|
23
|
+
df,
|
|
24
|
+
x=x,
|
|
25
|
+
y=y,
|
|
26
|
+
title=title,
|
|
27
|
+
markers=True,
|
|
28
|
+
)
|
|
29
|
+
return fig
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def interactive_bar(
|
|
33
|
+
df: pd.DataFrame,
|
|
34
|
+
x: str,
|
|
35
|
+
y: str,
|
|
36
|
+
title: str = "",
|
|
37
|
+
) -> go.Figure:
|
|
38
|
+
"""Create an interactive bar chart."""
|
|
39
|
+
return px.bar(df, x=x, y=y, title=title)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def interactive_scatter(
|
|
43
|
+
df: pd.DataFrame,
|
|
44
|
+
x: str,
|
|
45
|
+
y: str,
|
|
46
|
+
title: str = "",
|
|
47
|
+
color: str | None = None,
|
|
48
|
+
) -> go.Figure:
|
|
49
|
+
"""Create an interactive scatter plot."""
|
|
50
|
+
return px.scatter(
|
|
51
|
+
df,
|
|
52
|
+
x=x,
|
|
53
|
+
y=y,
|
|
54
|
+
color=color,
|
|
55
|
+
title=title,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_interactive_chart(
|
|
60
|
+
fig: go.Figure,
|
|
61
|
+
path: str | Path,
|
|
62
|
+
) -> Path:
|
|
63
|
+
"""Export an interactive chart as standalone HTML."""
|
|
64
|
+
path = Path(path)
|
|
65
|
+
|
|
66
|
+
if path.suffix.lower() != ".html":
|
|
67
|
+
raise ValueError("Output file must have an .html extension.")
|
|
68
|
+
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
fig.write_html(
|
|
72
|
+
str(path),
|
|
73
|
+
include_plotlyjs=True,
|
|
74
|
+
full_html=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return path
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
|
|
2
|
+
"""Reproducible data processing pipelines for TANGRA DataForge."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from tangra_dataforge.collect import load_csv, load_json
|
|
10
|
+
from tangra_dataforge.clean import (
|
|
11
|
+
remove_duplicates,
|
|
12
|
+
remove_empty_rows,
|
|
13
|
+
)
|
|
14
|
+
from tangra_dataforge.analyze import describe_data
|
|
15
|
+
from tangra_dataforge.visualize import line_chart, save_chart
|
|
16
|
+
from tangra_dataforge.provenance import (
|
|
17
|
+
create_record,
|
|
18
|
+
save_record,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_pipeline(
|
|
23
|
+
input_path: str | Path,
|
|
24
|
+
output_dir: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
source_url: str | None = None,
|
|
27
|
+
chart_x: str | None = None,
|
|
28
|
+
chart_y: str | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Run a reproducible data analysis pipeline.
|
|
31
|
+
|
|
32
|
+
Supports CSV and JSON inputs. Outputs cleaned data,
|
|
33
|
+
descriptive statistics, optional visualization and
|
|
34
|
+
a provenance record.
|
|
35
|
+
"""
|
|
36
|
+
input_path = Path(input_path)
|
|
37
|
+
output_dir = Path(output_dir)
|
|
38
|
+
|
|
39
|
+
if not input_path.is_file():
|
|
40
|
+
raise FileNotFoundError(input_path)
|
|
41
|
+
|
|
42
|
+
if input_path.suffix.lower() == ".csv":
|
|
43
|
+
df = load_csv(input_path)
|
|
44
|
+
elif input_path.suffix.lower() == ".json":
|
|
45
|
+
df = load_json(input_path)
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError("Supported formats: CSV and JSON.")
|
|
48
|
+
|
|
49
|
+
original_rows = len(df)
|
|
50
|
+
|
|
51
|
+
df = remove_duplicates(df)
|
|
52
|
+
df = remove_empty_rows(df)
|
|
53
|
+
|
|
54
|
+
if (chart_x is None) != (chart_y is None):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Both chart_x and chart_y must be provided."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if chart_x is not None:
|
|
60
|
+
missing = {
|
|
61
|
+
chart_x, chart_y
|
|
62
|
+
} - set(df.columns)
|
|
63
|
+
|
|
64
|
+
if missing:
|
|
65
|
+
raise KeyError(f"Missing chart columns: {missing}")
|
|
66
|
+
|
|
67
|
+
statistics = describe_data(df) if not df.empty else pd.DataFrame()
|
|
68
|
+
|
|
69
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
cleaned_path = output_dir / "cleaned_data.csv"
|
|
72
|
+
stats_path = output_dir / "statistics.csv"
|
|
73
|
+
provenance_path = output_dir / "provenance.json"
|
|
74
|
+
|
|
75
|
+
df.to_csv(cleaned_path, index=False)
|
|
76
|
+
statistics.to_csv(stats_path)
|
|
77
|
+
|
|
78
|
+
chart_path = None
|
|
79
|
+
|
|
80
|
+
if chart_x is not None and not df.empty:
|
|
81
|
+
import matplotlib.pyplot as plt
|
|
82
|
+
|
|
83
|
+
chart_path = output_dir / "chart.png"
|
|
84
|
+
|
|
85
|
+
fig = line_chart(
|
|
86
|
+
df,
|
|
87
|
+
chart_x,
|
|
88
|
+
chart_y,
|
|
89
|
+
title=f"{chart_y} by {chart_x}",
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
save_chart(fig, chart_path)
|
|
93
|
+
finally:
|
|
94
|
+
plt.close(fig)
|
|
95
|
+
|
|
96
|
+
record = create_record(
|
|
97
|
+
source_url or input_path.resolve().as_uri(),
|
|
98
|
+
input_path,
|
|
99
|
+
transformations=[
|
|
100
|
+
"Removed duplicate rows",
|
|
101
|
+
"Removed completely empty rows",
|
|
102
|
+
"Calculated descriptive statistics",
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
record["original_rows"] = original_rows
|
|
107
|
+
record["processed_rows"] = len(df)
|
|
108
|
+
record["outputs"] = {
|
|
109
|
+
"cleaned_data": str(cleaned_path),
|
|
110
|
+
"statistics": str(stats_path),
|
|
111
|
+
"chart": str(chart_path) if chart_path else None,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
save_record(record, provenance_path)
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"data": df,
|
|
118
|
+
"statistics": statistics,
|
|
119
|
+
"provenance": record,
|
|
120
|
+
"output_dir": output_dir,
|
|
121
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
"""Dataset provenance tracking for TANGRA DataForge."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def file_checksum(path: str | Path) -> str:
|
|
12
|
+
"""Calculate a SHA-256 checksum for a dataset."""
|
|
13
|
+
digest = hashlib.sha256()
|
|
14
|
+
|
|
15
|
+
with open(path, "rb") as file:
|
|
16
|
+
for chunk in iter(lambda: file.read(65536), b""):
|
|
17
|
+
digest.update(chunk)
|
|
18
|
+
|
|
19
|
+
return digest.hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_record(
|
|
23
|
+
source_url: str,
|
|
24
|
+
dataset_path: str | Path,
|
|
25
|
+
transformations: list[str] | None = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""Create a reproducible dataset provenance record."""
|
|
28
|
+
path = Path(dataset_path)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"source_url": source_url,
|
|
32
|
+
"dataset": path.name,
|
|
33
|
+
"collected_at": datetime.now(
|
|
34
|
+
timezone.utc
|
|
35
|
+
).isoformat(),
|
|
36
|
+
"sha256": file_checksum(path),
|
|
37
|
+
"transformations": transformations or [],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_record(
|
|
42
|
+
record: dict[str, Any],
|
|
43
|
+
output_path: str | Path,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Save a provenance record as JSON."""
|
|
46
|
+
path = Path(output_path)
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
path.write_text(
|
|
50
|
+
json.dumps(record, indent=2, ensure_ascii=False),
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
)
|
|
53
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
|
|
2
|
+
"""Web scraping utilities for TANGRA DataForge.
|
|
3
|
+
|
|
4
|
+
Extract structured information from web pages using Scrapy
|
|
5
|
+
and convert the collected records into Pandas DataFrames.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_data(
|
|
14
|
+
html: str,
|
|
15
|
+
selectors: dict[str, str],
|
|
16
|
+
) -> pd.DataFrame:
|
|
17
|
+
"""Extract fields from an HTML document using CSS selectors.
|
|
18
|
+
|
|
19
|
+
Each CSS selector extracts text from matching elements.
|
|
20
|
+
Fields are aligned by their order in the document.
|
|
21
|
+
Missing values are represented by None.
|
|
22
|
+
"""
|
|
23
|
+
from scrapy import Selector
|
|
24
|
+
|
|
25
|
+
if not selectors:
|
|
26
|
+
raise ValueError("At least one selector is required.")
|
|
27
|
+
|
|
28
|
+
selector = Selector(text=html)
|
|
29
|
+
extracted = {}
|
|
30
|
+
|
|
31
|
+
for field, css in selectors.items():
|
|
32
|
+
values = selector.css(css).getall()
|
|
33
|
+
extracted[field] = [
|
|
34
|
+
Selector(text=value).xpath("string(.)").get(
|
|
35
|
+
default=""
|
|
36
|
+
).strip()
|
|
37
|
+
for value in values
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
max_length = max(
|
|
41
|
+
(len(values) for values in extracted.values()),
|
|
42
|
+
default=0,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for field, values in extracted.items():
|
|
46
|
+
values.extend([None] * (max_length - len(values)))
|
|
47
|
+
|
|
48
|
+
return pd.DataFrame(extracted)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_links(
|
|
52
|
+
html: str,
|
|
53
|
+
base_url: str,
|
|
54
|
+
) -> list[str]:
|
|
55
|
+
"""Extract and normalize links from an HTML document."""
|
|
56
|
+
from urllib.parse import urljoin, urlparse
|
|
57
|
+
from scrapy import Selector
|
|
58
|
+
|
|
59
|
+
selector = Selector(text=html)
|
|
60
|
+
links = []
|
|
61
|
+
|
|
62
|
+
for href in selector.css("a::attr(href)").getall():
|
|
63
|
+
url = urljoin(base_url, href)
|
|
64
|
+
|
|
65
|
+
if urlparse(url).scheme not in ("http", "https"):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
if url not in links:
|
|
69
|
+
links.append(url)
|
|
70
|
+
|
|
71
|
+
return links
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_table(html: str) -> pd.DataFrame:
|
|
75
|
+
"""Extract the first HTML table into a DataFrame."""
|
|
76
|
+
from io import StringIO
|
|
77
|
+
|
|
78
|
+
tables = pd.read_html(StringIO(html))
|
|
79
|
+
|
|
80
|
+
if not tables:
|
|
81
|
+
raise ValueError("No HTML tables found.")
|
|
82
|
+
|
|
83
|
+
return tables[0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def records_to_dataframe(
|
|
87
|
+
records: list[dict[str, Any]],
|
|
88
|
+
) -> pd.DataFrame:
|
|
89
|
+
"""Convert scraped records into a Pandas DataFrame."""
|
|
90
|
+
return pd.DataFrame.from_records(records)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
|
|
2
|
+
"""Statistical research utilities for TANGRA DataForge.
|
|
3
|
+
|
|
4
|
+
Correlation analysis, hypothesis testing, confidence
|
|
5
|
+
intervals, and distribution analysis using SciPy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from scipy import stats
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _paired_values(
|
|
16
|
+
df: pd.DataFrame,
|
|
17
|
+
column_a: str,
|
|
18
|
+
column_b: str,
|
|
19
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
20
|
+
"""Return numeric pairs after removing missing observations."""
|
|
21
|
+
pairs = df[[column_a, column_b]].dropna()
|
|
22
|
+
|
|
23
|
+
a = pairs[column_a].to_numpy(dtype=float)
|
|
24
|
+
b = pairs[column_b].to_numpy(dtype=float)
|
|
25
|
+
|
|
26
|
+
if not np.isfinite(a).all() or not np.isfinite(b).all():
|
|
27
|
+
raise ValueError("Values must be finite.")
|
|
28
|
+
|
|
29
|
+
return a, b
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pearson_correlation(
|
|
33
|
+
df: pd.DataFrame,
|
|
34
|
+
column_a: str,
|
|
35
|
+
column_b: str,
|
|
36
|
+
) -> dict[str, float]:
|
|
37
|
+
"""Calculate Pearson's linear correlation and p-value."""
|
|
38
|
+
a, b = _paired_values(df, column_a, column_b)
|
|
39
|
+
|
|
40
|
+
if len(a) < 3:
|
|
41
|
+
raise ValueError("At least 3 valid pairs are required.")
|
|
42
|
+
|
|
43
|
+
result = stats.pearsonr(a, b)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"correlation": float(result.statistic),
|
|
47
|
+
"p_value": float(result.pvalue),
|
|
48
|
+
"sample_size": len(a),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def spearman_correlation(
|
|
53
|
+
df: pd.DataFrame,
|
|
54
|
+
column_a: str,
|
|
55
|
+
column_b: str,
|
|
56
|
+
) -> dict[str, float]:
|
|
57
|
+
"""Calculate Spearman's rank correlation and p-value."""
|
|
58
|
+
a, b = _paired_values(df, column_a, column_b)
|
|
59
|
+
|
|
60
|
+
if len(a) < 3:
|
|
61
|
+
raise ValueError("At least 3 valid pairs are required.")
|
|
62
|
+
|
|
63
|
+
result = stats.spearmanr(a, b)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"correlation": float(result.statistic),
|
|
67
|
+
"p_value": float(result.pvalue),
|
|
68
|
+
"sample_size": len(a),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compare_means(
|
|
73
|
+
group_a: pd.Series,
|
|
74
|
+
group_b: pd.Series,
|
|
75
|
+
) -> dict[str, float]:
|
|
76
|
+
"""Compare independent group means using Welch's t-test."""
|
|
77
|
+
a = np.asarray(pd.to_numeric(group_a.dropna()), dtype=float)
|
|
78
|
+
b = np.asarray(pd.to_numeric(group_b.dropna()), dtype=float)
|
|
79
|
+
|
|
80
|
+
if len(a) < 2 or len(b) < 2:
|
|
81
|
+
raise ValueError("Each group requires at least 2 values.")
|
|
82
|
+
|
|
83
|
+
if not np.isfinite(a).all() or not np.isfinite(b).all():
|
|
84
|
+
raise ValueError("Values must be finite.")
|
|
85
|
+
|
|
86
|
+
result = stats.ttest_ind(
|
|
87
|
+
a,
|
|
88
|
+
b,
|
|
89
|
+
equal_var=False,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"mean_a": float(np.mean(a)),
|
|
94
|
+
"mean_b": float(np.mean(b)),
|
|
95
|
+
"t_statistic": float(result.statistic),
|
|
96
|
+
"p_value": float(result.pvalue),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def confidence_interval(
|
|
101
|
+
values: pd.Series,
|
|
102
|
+
confidence: float = 0.95,
|
|
103
|
+
) -> tuple[float, float]:
|
|
104
|
+
"""Calculate a Student's t confidence interval for the mean."""
|
|
105
|
+
if not 0 < confidence < 1:
|
|
106
|
+
raise ValueError("confidence must be between 0 and 1.")
|
|
107
|
+
|
|
108
|
+
data = np.asarray(
|
|
109
|
+
pd.to_numeric(values.dropna()),
|
|
110
|
+
dtype=float,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if len(data) < 2:
|
|
114
|
+
raise ValueError("At least 2 values are required.")
|
|
115
|
+
|
|
116
|
+
if not np.isfinite(data).all():
|
|
117
|
+
raise ValueError("Values must be finite.")
|
|
118
|
+
|
|
119
|
+
mean = float(np.mean(data))
|
|
120
|
+
sem = float(stats.sem(data))
|
|
121
|
+
|
|
122
|
+
if sem == 0:
|
|
123
|
+
return mean, mean
|
|
124
|
+
|
|
125
|
+
lower, upper = stats.t.interval(
|
|
126
|
+
confidence=confidence,
|
|
127
|
+
df=len(data) - 1,
|
|
128
|
+
loc=mean,
|
|
129
|
+
scale=sem,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return float(lower), float(upper)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normality_test(
|
|
136
|
+
values: pd.Series,
|
|
137
|
+
) -> dict[str, float]:
|
|
138
|
+
"""Perform the Shapiro-Wilk normality test."""
|
|
139
|
+
data = np.asarray(
|
|
140
|
+
pd.to_numeric(values.dropna()),
|
|
141
|
+
dtype=float,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not 3 <= len(data) <= 5000:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"Shapiro-Wilk requires 3 to 5000 observations."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not np.isfinite(data).all():
|
|
150
|
+
raise ValueError("Values must be finite.")
|
|
151
|
+
|
|
152
|
+
result = stats.shapiro(data)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"statistic": float(result.statistic),
|
|
156
|
+
"p_value": float(result.pvalue),
|
|
157
|
+
"sample_size": len(data),
|
|
158
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
|
|
2
|
+
"""Data quality validation utilities for TANGRA DataForge.
|
|
3
|
+
|
|
4
|
+
Validate datasets before statistical analysis,
|
|
5
|
+
visualization, and investigative research.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from pandas.api.types import is_numeric_dtype
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_required_columns(
|
|
15
|
+
df: pd.DataFrame,
|
|
16
|
+
required: list[str],
|
|
17
|
+
) -> list[str]:
|
|
18
|
+
"""Return the names of missing required columns."""
|
|
19
|
+
return [col for col in required if col not in df.columns]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_missing_values(
|
|
23
|
+
df: pd.DataFrame,
|
|
24
|
+
) -> pd.DataFrame:
|
|
25
|
+
"""Report missing values for every column."""
|
|
26
|
+
total = df.isna().sum()
|
|
27
|
+
|
|
28
|
+
percentage = (
|
|
29
|
+
total / len(df) * 100
|
|
30
|
+
if len(df)
|
|
31
|
+
else total.astype(float)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return pd.DataFrame({
|
|
35
|
+
"column": df.columns,
|
|
36
|
+
"missing_count": total.to_numpy(),
|
|
37
|
+
"missing_percent": percentage.to_numpy(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_duplicates(
|
|
42
|
+
df: pd.DataFrame,
|
|
43
|
+
subset: list[str] | None = None,
|
|
44
|
+
) -> pd.DataFrame:
|
|
45
|
+
"""Return duplicated rows, excluding first occurrences."""
|
|
46
|
+
return df.loc[
|
|
47
|
+
df.duplicated(subset=subset, keep="first")
|
|
48
|
+
].copy()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_numeric_range(
|
|
52
|
+
df: pd.DataFrame,
|
|
53
|
+
column: str,
|
|
54
|
+
minimum: float | None = None,
|
|
55
|
+
maximum: float | None = None,
|
|
56
|
+
) -> pd.DataFrame:
|
|
57
|
+
"""Return rows with values outside the allowed range."""
|
|
58
|
+
if column not in df.columns:
|
|
59
|
+
raise KeyError(f"Column not found: {column}")
|
|
60
|
+
|
|
61
|
+
if not is_numeric_dtype(df[column]):
|
|
62
|
+
raise TypeError(f"Column '{column}' must be numeric.")
|
|
63
|
+
|
|
64
|
+
if minimum is not None and maximum is not None:
|
|
65
|
+
if minimum > maximum:
|
|
66
|
+
raise ValueError("minimum cannot exceed maximum")
|
|
67
|
+
|
|
68
|
+
mask = pd.Series(False, index=df.index)
|
|
69
|
+
|
|
70
|
+
if minimum is not None:
|
|
71
|
+
mask |= df[column] < minimum
|
|
72
|
+
|
|
73
|
+
if maximum is not None:
|
|
74
|
+
mask |= df[column] > maximum
|
|
75
|
+
|
|
76
|
+
return df.loc[mask].copy()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_dataset(
|
|
80
|
+
df: pd.DataFrame,
|
|
81
|
+
required_columns: list[str] | None = None,
|
|
82
|
+
numeric_ranges: dict[
|
|
83
|
+
str, tuple[float | None, float | None]
|
|
84
|
+
] | None = None,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""Generate a data quality validation report."""
|
|
87
|
+
required_columns = (
|
|
88
|
+
required_columns if required_columns is not None else []
|
|
89
|
+
)
|
|
90
|
+
numeric_ranges = (
|
|
91
|
+
numeric_ranges if numeric_ranges is not None else {}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
missing_columns = check_required_columns(
|
|
95
|
+
df, required_columns
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
missing_values = check_missing_values(df)
|
|
99
|
+
duplicates = check_duplicates(df)
|
|
100
|
+
|
|
101
|
+
range_violations = {}
|
|
102
|
+
|
|
103
|
+
for column, (minimum, maximum) in numeric_ranges.items():
|
|
104
|
+
range_violations[column] = check_numeric_range(
|
|
105
|
+
df, column, minimum, maximum
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
has_missing = bool(df.isna().any().any())
|
|
109
|
+
has_duplicates = not duplicates.empty
|
|
110
|
+
has_range_errors = any(
|
|
111
|
+
not rows.empty
|
|
112
|
+
for rows in range_violations.values()
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"valid": not (
|
|
117
|
+
missing_columns
|
|
118
|
+
or has_missing
|
|
119
|
+
or has_duplicates
|
|
120
|
+
or has_range_errors
|
|
121
|
+
),
|
|
122
|
+
"total_rows": len(df),
|
|
123
|
+
"total_columns": len(df.columns),
|
|
124
|
+
"missing_columns": missing_columns,
|
|
125
|
+
"missing_values": missing_values,
|
|
126
|
+
"duplicate_count": len(duplicates),
|
|
127
|
+
"range_violations": range_violations,
|
|
128
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
|
|
2
|
+
"""Data visualization utilities for TANGRA DataForge.
|
|
3
|
+
|
|
4
|
+
Create and export charts for data journalism,
|
|
5
|
+
economic research, and statistical analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from matplotlib.figure import Figure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def line_chart(
|
|
16
|
+
df: pd.DataFrame,
|
|
17
|
+
x: str,
|
|
18
|
+
y: str,
|
|
19
|
+
title: str = "",
|
|
20
|
+
) -> Figure:
|
|
21
|
+
"""Create a line chart for trends over time."""
|
|
22
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
23
|
+
|
|
24
|
+
ax.plot(df[x], df[y], marker="o", linewidth=2)
|
|
25
|
+
|
|
26
|
+
ax.set_title(title)
|
|
27
|
+
ax.set_xlabel(x)
|
|
28
|
+
ax.set_ylabel(y)
|
|
29
|
+
ax.grid(True, alpha=0.3)
|
|
30
|
+
|
|
31
|
+
fig.tight_layout()
|
|
32
|
+
return fig
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bar_chart(
|
|
36
|
+
df: pd.DataFrame,
|
|
37
|
+
x: str,
|
|
38
|
+
y: str,
|
|
39
|
+
title: str = "",
|
|
40
|
+
) -> Figure:
|
|
41
|
+
"""Create a bar chart for comparing categories."""
|
|
42
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
43
|
+
|
|
44
|
+
ax.bar(df[x], df[y])
|
|
45
|
+
|
|
46
|
+
ax.set_title(title)
|
|
47
|
+
ax.set_xlabel(x)
|
|
48
|
+
ax.set_ylabel(y)
|
|
49
|
+
|
|
50
|
+
fig.tight_layout()
|
|
51
|
+
return fig
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def scatter_chart(
|
|
55
|
+
df: pd.DataFrame,
|
|
56
|
+
x: str,
|
|
57
|
+
y: str,
|
|
58
|
+
title: str = "",
|
|
59
|
+
) -> Figure:
|
|
60
|
+
"""Create a scatter plot for exploring relationships."""
|
|
61
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
62
|
+
|
|
63
|
+
ax.scatter(df[x], df[y])
|
|
64
|
+
|
|
65
|
+
ax.set_title(title)
|
|
66
|
+
ax.set_xlabel(x)
|
|
67
|
+
ax.set_ylabel(y)
|
|
68
|
+
|
|
69
|
+
fig.tight_layout()
|
|
70
|
+
return fig
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save_chart(
|
|
74
|
+
fig: Figure,
|
|
75
|
+
path: str | Path,
|
|
76
|
+
dpi: int = 300,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Save a chart to PNG, SVG, PDF, or another supported format."""
|
|
79
|
+
path = Path(path)
|
|
80
|
+
|
|
81
|
+
path.parent.mkdir(
|
|
82
|
+
parents=True,
|
|
83
|
+
exist_ok=True,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
fig.savefig(
|
|
87
|
+
path,
|
|
88
|
+
dpi=dpi,
|
|
89
|
+
bbox_inches="tight",
|
|
90
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tangra-dataforge
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Python toolkit for data collection, cleaning, analysis, and investigative journalism.
|
|
5
|
+
Author: Irena Popova
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: matplotlib>=3.8
|
|
10
|
+
Requires-Dist: openpyxl>=3.1
|
|
11
|
+
Requires-Dist: pandas>=2.0
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
Provides-Extra: analytics
|
|
14
|
+
Requires-Dist: duckdb>=1.1; extra == 'analytics'
|
|
15
|
+
Requires-Dist: pyarrow>=17.0; extra == 'analytics'
|
|
16
|
+
Requires-Dist: scipy>=1.13; extra == 'analytics'
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: mypy>=1.14; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
22
|
+
Provides-Extra: scraping
|
|
23
|
+
Requires-Dist: beautifulsoup4>=4.12; extra == 'scraping'
|
|
24
|
+
Requires-Dist: httpx>=0.27; extra == 'scraping'
|
|
25
|
+
Requires-Dist: lxml>=5.0; extra == 'scraping'
|
|
26
|
+
Requires-Dist: playwright>=1.50; extra == 'scraping'
|
|
27
|
+
Requires-Dist: scrapy>=2.12; extra == 'scraping'
|
|
28
|
+
Provides-Extra: visualization
|
|
29
|
+
Requires-Dist: geopandas>=1.0; extra == 'visualization'
|
|
30
|
+
Requires-Dist: plotly>=5.24; extra == 'visualization'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# TANGRA DataForge
|
|
34
|
+
|
|
35
|
+
**Open-Source Python Toolkit for Data Engineering, Analysis & Investigative Journalism**
|
|
36
|
+
|
|
37
|
+
TANGRA DataForge is an open-source Python project focused on collecting, processing, analyzing, and visualizing data. It is designed for data journalism, investigative research, and reproducible data analysis.
|
|
38
|
+
|
|
39
|
+
The project aims to transform raw data from public datasets, websites, and APIs into structured, verifiable, and meaningful information.
|
|
40
|
+
|
|
41
|
+
## Project Goals
|
|
42
|
+
|
|
43
|
+
- Collect data from public APIs, websites, CSV, JSON, and Excel files.
|
|
44
|
+
- Extract structured information through web scraping.
|
|
45
|
+
- Clean, validate, and transform datasets using Python and Pandas.
|
|
46
|
+
- Perform statistical analysis and identify patterns, trends, and anomalies.
|
|
47
|
+
- Create informative visualizations and charts.
|
|
48
|
+
- Support reproducible investigations with transparent methodologies and documented sources.
|
|
49
|
+
|
|
50
|
+
## Technology Stack
|
|
51
|
+
|
|
52
|
+
- **Python** — Core programming language
|
|
53
|
+
- **Pandas** — Data manipulation and analysis
|
|
54
|
+
- **NumPy** — Numerical computing
|
|
55
|
+
- **Beautiful Soup** — HTML parsing and web scraping
|
|
56
|
+
- **Requests** — HTTP requests and API integration
|
|
57
|
+
- **Matplotlib / Plotly** — Data visualization
|
|
58
|
+
- **pytest** — Automated testing
|
|
59
|
+
- **Git / GitHub** — Version control and collaboration
|
|
60
|
+
|
|
61
|
+
These technologies represent the planned toolkit. Dependencies will be introduced as features are implemented.
|
|
62
|
+
|
|
63
|
+
## Planned Architecture
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
tangra-dataforge/
|
|
67
|
+
├── src/
|
|
68
|
+
│ └── tangra_dataforge/
|
|
69
|
+
│ ├── __init__.py
|
|
70
|
+
│ ├── collect.py
|
|
71
|
+
│ ├── clean.py
|
|
72
|
+
│ ├── analyze.py
|
|
73
|
+
│ └── visualize.py
|
|
74
|
+
├── investigations/
|
|
75
|
+
├── examples/
|
|
76
|
+
├── tests/
|
|
77
|
+
├── pyproject.toml
|
|
78
|
+
├── README.md
|
|
79
|
+
└── .gitignore
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Data Journalism & Research
|
|
83
|
+
|
|
84
|
+
TANGRA DataForge is being developed to support data-driven investigations into economic, social, political, and technological developments.
|
|
85
|
+
|
|
86
|
+
Planned research applications include:
|
|
87
|
+
|
|
88
|
+
- Economic indicators and regional inequality
|
|
89
|
+
- Housing affordability and income development
|
|
90
|
+
- Labour markets and workforce shortages
|
|
91
|
+
- Education and professional qualifications
|
|
92
|
+
- Technology, digital transformation, and society
|
|
93
|
+
|
|
94
|
+
Investigations will prioritize publicly accessible data, source attribution, transparent analysis, and reproducible results.
|
|
95
|
+
|
|
96
|
+
## Development Status
|
|
97
|
+
|
|
98
|
+
**Early development — project initialization**
|
|
99
|
+
|
|
100
|
+
The toolkit is currently being established. Its first development phase will focus on Python/Pandas data processing, dataset validation, and reproducible analysis workflows.
|
|
101
|
+
|
|
102
|
+
The project is not yet available as an installable Python package.
|
|
103
|
+
|
|
104
|
+
## Roadmap
|
|
105
|
+
|
|
106
|
+
- [ ] Establish Python package structure
|
|
107
|
+
- [ ] Implement CSV and JSON data loading
|
|
108
|
+
- [ ] Develop Pandas-based cleaning functions
|
|
109
|
+
- [ ] Add data validation and descriptive statistics
|
|
110
|
+
- [ ] Integrate API data collection
|
|
111
|
+
- [ ] Implement web scraping components
|
|
112
|
+
- [ ] Add visualization utilities
|
|
113
|
+
- [ ] Create a reproducible journalism case study
|
|
114
|
+
- [ ] Add automated tests and documentation
|
|
115
|
+
- [ ] Publish an initial release on PyPI
|
|
116
|
+
|
|
117
|
+
## Responsible Data Collection
|
|
118
|
+
|
|
119
|
+
Data collection functionality will be developed with attention to applicable laws, data protection, website access policies, rate limits, and source attribution.
|
|
120
|
+
|
|
121
|
+
## Author
|
|
122
|
+
|
|
123
|
+
**Irena Popova**
|
|
124
|
+
|
|
125
|
+
Independent Researcher | Python & Full-Stack Developer | Author & Publisher
|
|
126
|
+
|
|
127
|
+
[Website](https://www.irena-popova.com) | [GitHub](https://github.com/irenapopova)
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
An open-source license will be added before the first public software release.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
tangra_dataforge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tangra_dataforge/analyze.py,sha256=-bfBVdvC51mS2ACj9dsCR8BT1pm4zJVNq05LhjIe-kk,2062
|
|
3
|
+
tangra_dataforge/browser.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
tangra_dataforge/clean.py,sha256=74WajaJ4K-dJLtRPyiC7e1RoW9RIDj8d94RRCkSSX4U,406
|
|
5
|
+
tangra_dataforge/collect.py,sha256=nkXqJ5qvbz90-2JS0tJnMfylznHbzb7Fz1EUBIA32ZU,1365
|
|
6
|
+
tangra_dataforge/database.py,sha256=Wypgmx85vUHmStwqx0EOcSkDgc_oX12Z8t1K31l8FFQ,1074
|
|
7
|
+
tangra_dataforge/geospatial.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
tangra_dataforge/interactive.py,sha256=kHeAvAOdmi6WkL3c97reHJzq_iiGhRQKX6Xg5RrmxjM,1459
|
|
9
|
+
tangra_dataforge/pipeline.py,sha256=DLNgkEX66XJMh_PLGRkjFmdjzq4kyiPlZuiEtrYGSb4,3131
|
|
10
|
+
tangra_dataforge/provenance.py,sha256=fhEakXVN0sYWK4Cf5StsvJ4w5nX-8oYRicfAXfSwALg,1272
|
|
11
|
+
tangra_dataforge/scrape.py,sha256=_7zBegnrZEUIziJUiVA-B9aVE_6R8Y6Ihga2vWB2jwM,2184
|
|
12
|
+
tangra_dataforge/statistics.py,sha256=PaFpa-Nq2qnem5IdxRALjCvsUUBxRXeaIo25792SQ-s,3862
|
|
13
|
+
tangra_dataforge/validate.py,sha256=LnV96WufwbcM96aMDMhpt99VKsH_iF-K0Pn9gQTGUiw,3300
|
|
14
|
+
tangra_dataforge/visualize.py,sha256=DdD2xhP4lhbTjwOQirBxu5pAftV6qf2-TmE3n17uDqY,1657
|
|
15
|
+
tangra_dataforge-0.1.0a1.dist-info/METADATA,sha256=r0N0IBucaKTX_Zj3gOSuIjA7NdzvStjGg4Hb9Cvx1X8,4775
|
|
16
|
+
tangra_dataforge-0.1.0a1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
17
|
+
tangra_dataforge-0.1.0a1.dist-info/licenses/LICENSE,sha256=VpYmhrCp13HYZdQu-eQirM8bZ3MLYln1aHB0bpO1aPI,1069
|
|
18
|
+
tangra_dataforge-0.1.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Irena Popova
|
|
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
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
16
|
+
OR 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
|
|
21
|
+
THE SOFTWARE.
|