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.
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.