ullr-data-cli 1.0.2__tar.gz → 1.0.3__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ullr-data-cli
3
- Version: 1.0.2
4
- Summary: An offline, AI-free Data Analytics and Dashboard Auditing CLI
3
+ Version: 1.0.3
4
+ Summary: A purely native Practice Engine for aspiring Data Analysts to generate and audit datasets
5
5
  Home-page: https://github.com/jshlydnzl/ullr-data-cli
6
6
  Author: jshlydnzl
7
7
  Author-email: jshlydnzl@users.noreply.github.com
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: rich
16
16
  Requires-Dist: pandas
17
17
  Requires-Dist: openpyxl
18
+ Requires-Dist: faker
18
19
  Dynamic: author
19
20
  Dynamic: author-email
20
21
  Dynamic: classifier
@@ -0,0 +1,28 @@
1
+ # Ullr Data Engine
2
+
3
+ ![Ullr Interactive Menu](https://raw.githubusercontent.com/jshlydnzl/ullr-data-cli/master/screenshot.jpeg)
4
+
5
+ A purely native Practice Engine for aspiring Data Analysts to generate and audit datasets.
6
+
7
+ Ullr is not an automated BI tool that does your job for you. It generates intentionally dirty data, gives you realistic business context, and audits your raw files so you can practice cleaning and analyzing data natively in Excel or SQL.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install ullr-data-cli
13
+ ```
14
+
15
+ ## How to Practice Data Analytics with Ullr
16
+
17
+ Ullr is built with a beautiful interactive terminal menu. Just type `ullr` in your terminal to launch the engine and follow these three steps:
18
+
19
+ ### Step 1: Generate the Dirty Data
20
+ Launch the interactive menu and select **Generate Data** (Option 2).
21
+ Ullr will ask you to choose from 7 different industries (e.g., E-Commerce, Healthcare, SaaS). It will generate hundreds of rows of intentionally flawed data, and automatically output a **Stakeholder Brief** containing a realistic business scenario and the exact KPIs you need to build.
22
+
23
+ ### Step 2: Audit the Damage
24
+ Now that you have your dataset, launch the menu again and select **Audit Data** (Option 1).
25
+ Ullr will auto-detect the datasets in your folder. Select your file, and Ullr will scan it to give you a full forensic report on missing values, hidden blanks, duplicates, and data type errors.
26
+
27
+ ### Step 3: Clean and Analyze (Your Job!)
28
+ Now, open the dataset in Excel or SQL. Using Ullr's Stakeholder Brief and Audit Report as your guide, clean the data yourself and build out the requested dashboards. No AI crutches—just pure engineering practice.
@@ -2,10 +2,10 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='ullr-data-cli',
5
- version='1.0.2',
5
+ version='1.0.3',
6
6
  author='jshlydnzl',
7
7
  author_email='jshlydnzl@users.noreply.github.com',
8
- description='An offline, AI-free Data Analytics and Dashboard Auditing CLI',
8
+ description='A purely native Practice Engine for aspiring Data Analysts to generate and audit datasets',
9
9
  long_description='Ullr is a pure Python Data Engine that automatically audits CSV/Excel files for data quality (ghost data, invisible spaces, duplicates) and generates Dynamic Dashboard Blueprints for Excel, Power BI, Tableau, and Looker Studio.',
10
10
  long_description_content_type='text/markdown',
11
11
  url='https://github.com/jshlydnzl/ullr-data-cli',
@@ -13,7 +13,8 @@ setup(
13
13
  install_requires=[
14
14
  'rich',
15
15
  'pandas',
16
- 'openpyxl'
16
+ 'openpyxl',
17
+ 'faker'
17
18
  ],
18
19
  classifiers=[
19
20
  'Programming Language :: Python :: 3',
@@ -0,0 +1,554 @@
1
+ import argparse
2
+ import pandas as pd
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich.panel import Panel
6
+ import os
7
+ import sys
8
+ import time
9
+ import json
10
+ import pathlib
11
+
12
+ console = Console()
13
+
14
+
15
+ CONFIG_FILE = os.path.join(pathlib.Path.home(), ".ullr_config.json")
16
+
17
+ def load_config():
18
+ if os.path.exists(CONFIG_FILE):
19
+ with open(CONFIG_FILE, "r") as f:
20
+ return json.load(f)
21
+ return {}
22
+
23
+ def save_config(config):
24
+ with open(CONFIG_FILE, "w") as f:
25
+ json.dump(config, f, indent=4)
26
+
27
+ def get_default_dir():
28
+ config = load_config()
29
+ if "default_dir" in config and os.path.exists(config["default_dir"]):
30
+ return config["default_dir"]
31
+
32
+ from rich.prompt import Prompt
33
+ console.print("\n[bold yellow]⚠️ First Time Setup: No default directory found.[/]")
34
+ console.print("Where should Ullr save generated practice datasets?")
35
+
36
+ while True:
37
+ user_dir = Prompt.ask("Enter directory path").strip()
38
+ if os.path.exists(user_dir) and os.path.isdir(user_dir):
39
+ config["default_dir"] = user_dir
40
+ save_config(config)
41
+ console.print(f"[bold green]✔ Saved![/] Ullr will now save files to: {user_dir}\n")
42
+ return user_dir
43
+ else:
44
+ console.print("[bold red]❌ That directory does not exist. Please try again.[/]")
45
+
46
+ def change_default_dir():
47
+ config = load_config()
48
+ current = config.get("default_dir", "None")
49
+ console.print(f"\n[bold cyan]Current Default Directory:[/] {current}")
50
+
51
+ from rich.prompt import Prompt
52
+ user_dir = Prompt.ask("Enter NEW directory path (or press Enter to cancel)").strip()
53
+ if not user_dir:
54
+ return
55
+
56
+ if os.path.exists(user_dir) and os.path.isdir(user_dir):
57
+ config["default_dir"] = user_dir
58
+ save_config(config)
59
+ console.print(f"[bold green]✔ Directory Updated to:[/] {user_dir}\n")
60
+ else:
61
+ console.print("[bold red]❌ That directory does not exist. Update canceled.[/]")
62
+
63
+ def print_banner():
64
+
65
+ banner = """
66
+ ██╗ ██╗██╗ ██╗ ██████╗
67
+ ██║ ██║██║ ██║ ██╔══██╗
68
+ ██║ ██║██║ ██║ ██████╔╝
69
+ ██║ ██║██║ ██║ ██╔══██╗
70
+ ╚██████╔╝███████╗███████╗██║ ██║
71
+ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝
72
+ The Offline Data Auditor
73
+ """
74
+ console.print(banner, style="bold cyan")
75
+
76
+ import pathlib
77
+
78
+ def find_file_globally(filename):
79
+ if not filename or filename.strip() == "":
80
+ return None
81
+
82
+ home_dir = str(pathlib.Path.home())
83
+ search_dirs = [
84
+ os.path.join(home_dir, "Downloads"),
85
+ os.path.join(home_dir, "Documents"),
86
+ os.path.join(home_dir, "Desktop")
87
+ ]
88
+
89
+ with console.status(f"[yellow]Scanning Downloads, Documents, and Desktop for '{filename}'...[/]", spinner="dots"):
90
+ for base_dir in search_dirs:
91
+ if not os.path.exists(base_dir):
92
+ continue
93
+ for root, dirs, files in os.walk(base_dir):
94
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
95
+ if filename in files:
96
+ full_path = os.path.join(root, filename)
97
+ console.print(f"[bold green]Found it at:[/] {full_path}\n")
98
+ return full_path
99
+
100
+ return None
101
+
102
+ def load_dataframe(filepath):
103
+ """Smart loader that handles CSVs and multi-tab Excel files."""
104
+ try:
105
+ if filepath.lower().endswith('.csv'):
106
+ with console.status(f"[cyan]● Loading '{os.path.basename(filepath)}'...[/]", spinner="bouncingBar"):
107
+ time.sleep(0.6)
108
+ return pd.read_csv(filepath)
109
+ elif filepath.lower().endswith(('.xlsx', '.xls')):
110
+ xls = pd.ExcelFile(filepath)
111
+ sheet_names = xls.sheet_names
112
+
113
+ if len(sheet_names) == 1:
114
+ with console.status(f"[cyan]● Loading '{os.path.basename(filepath)}'...[/]", spinner="bouncingBar"):
115
+ time.sleep(0.6)
116
+ return pd.read_excel(xls, sheet_name=sheet_names[0])
117
+
118
+ from rich.prompt import Prompt
119
+ console.print(f"\n[bold cyan]📂 Multiple tabs detected in this Excel file![/]")
120
+ for i, sheet in enumerate(sheet_names):
121
+ console.print(f" [bold green]{i+1}.[/] {sheet}")
122
+
123
+ choices = [str(i+1) for i in range(len(sheet_names))]
124
+ choice = Prompt.ask("\nWhich tab would you like to load?", choices=choices, default="1")
125
+ selected_sheet = sheet_names[int(choice)-1]
126
+
127
+ with console.status(f"[cyan]● Loading tab: '{selected_sheet}'...[/]", spinner="bouncingBar"):
128
+ time.sleep(0.6)
129
+ return pd.read_excel(xls, sheet_name=selected_sheet)
130
+ else:
131
+ console.print(f"[bold red]Error:[/] Unsupported file format. Please provide a .csv or .xlsx file.")
132
+ return None
133
+ except Exception as e:
134
+ console.print(f"[bold red]Error reading file:[/] {e}")
135
+ return None
136
+
137
+ def audit_data(filepath):
138
+ if not os.path.exists(filepath):
139
+ found_path = find_file_globally(os.path.basename(filepath))
140
+ if found_path:
141
+ filepath = found_path
142
+ else:
143
+ console.print(f"[bold red]Error:[/] Could not find '{os.path.basename(filepath)}' anywhere on your computer.")
144
+ return
145
+
146
+ df = load_dataframe(filepath)
147
+ if df is None:
148
+ return
149
+
150
+ rows, cols = df.shape
151
+ console.print(f"\n[bold green]✅ Successfully loaded![/] {rows:,} rows, {cols} columns.\n")
152
+
153
+ # Separate real data columns from 'Unnamed' ghost columns
154
+ real_cols = [col for col in df.columns if not str(col).lower().startswith('unnamed')]
155
+ unnamed_cols = [col for col in df.columns if str(col).lower().startswith('unnamed')]
156
+
157
+ # 1. Check Missing Values (Only on real columns!)
158
+ missing = df[real_cols].isnull().sum()
159
+ actual_missing_cols = missing[missing > 0]
160
+
161
+ # 2. Check Duplicates
162
+ duplicates = df.duplicated().sum()
163
+
164
+ # 3. Check for Invisible Spaces (Messy Text)
165
+ text_cols = df[real_cols].select_dtypes(include=['object']).columns
166
+ space_issues = {}
167
+ dirty_numbers = {}
168
+
169
+ for col in text_cols:
170
+ # Find leading/trailing spaces
171
+ mask = df[col].notna() & df[col].astype(str).str.contains(r'^\s+|\s+$', regex=True)
172
+ spaces = mask.sum()
173
+ if spaces > 0:
174
+ space_issues[col] = spaces
175
+
176
+ # Find numbers trapped as text (like $1,000)
177
+ curr_mask = df[col].notna() & df[col].astype(str).str.contains(r'[\$£€,]', regex=True) & df[col].astype(str).str.contains(r'\d', regex=True)
178
+ dirty_num = curr_mask.sum()
179
+ if dirty_num > 0:
180
+ dirty_numbers[col] = dirty_num
181
+
182
+ # Build Output Panel
183
+ console.print("[bold yellow]🩺 DATA HEALTH CHECK REPORT[/]")
184
+ console.print("Here is what needs to be cleaned up before you can use this data:\n")
185
+
186
+ table = Table(show_header=True, header_style="bold magenta")
187
+ table.add_column("What we checked")
188
+ table.add_column("What we found")
189
+ table.add_column("Next Steps")
190
+
191
+ if duplicates > 0:
192
+ table.add_row("Copy-Paste Errors (Duplicates)", f"{duplicates:,} exact duplicate rows", "[bold red]Delete duplicates in Excel[/]")
193
+ else:
194
+ table.add_row("Copy-Paste Errors (Duplicates)", "0 duplicate rows", "[bold green]Looks Good![/]")
195
+
196
+ if not actual_missing_cols.empty:
197
+ table.add_row("Blank Cells (Missing Data)", f"Found in {len(actual_missing_cols)} columns", "[bold red]Fill in or remove blanks[/]")
198
+ else:
199
+ table.add_row("Blank Cells (Missing Data)", "0 blank cells", "[bold green]Looks Good![/]")
200
+
201
+ if space_issues:
202
+ table.add_row("Invisible Spaces (Messy Text)", f"Found in {len(space_issues)} columns", "[bold red]Use TRIM() in Excel[/]")
203
+ else:
204
+ table.add_row("Invisible Spaces (Messy Text)", "0 messy text cells", "[bold green]Looks Good![/]")
205
+
206
+ if dirty_numbers:
207
+ table.add_row("Numbers Trapped as Text", f"Found in {len(dirty_numbers)} columns", "[bold red]Remove $ or commas[/]")
208
+ else:
209
+ table.add_row("Numbers Trapped as Text", "0 trapped numbers", "[bold green]Looks Good![/]")
210
+
211
+ console.print(table)
212
+
213
+ if not actual_missing_cols.empty:
214
+ console.print("\n[bold red]⚠️ Where to find the Blank Cells:[/]")
215
+ for col, count in actual_missing_cols.items():
216
+ console.print(f" - Column [cyan]{col}[/]: {count:,} empty cells")
217
+
218
+ if space_issues:
219
+ console.print("\n[bold red]⚠️ Where to find Invisible Spaces:[/]")
220
+ for col, count in space_issues.items():
221
+ console.print(f" - Column [cyan]{col}[/]: {count:,} cells have hidden spaces.")
222
+
223
+ if dirty_numbers:
224
+ console.print("\n[bold red]⚠️ Where to find Trapped Numbers:[/]")
225
+ for col, count in dirty_numbers.items():
226
+ console.print(f" - Column [cyan]{col}[/]: {count:,} cells have $ or commas making them text.")
227
+
228
+ if unnamed_cols:
229
+ console.print(f"\n[bold yellow]👻 Note: We ignored {len(unnamed_cols)} 'Unnamed' columns.[/]")
230
+ console.print("Excel sometimes creates invisible columns if you have a floating summary table off to the right side of your data. We filtered them out to keep this report clean!")
231
+
232
+ if actual_missing_cols.empty and duplicates == 0 and not space_issues and not dirty_numbers:
233
+ console.print("\n[bold green]🎉 AMAZING! Your actual data is 100% clean and ready for analysis![/]")
234
+
235
+ def generate_data():
236
+ from rich.prompt import Prompt
237
+ try:
238
+ from faker import Faker
239
+ except ImportError:
240
+ console.print("[bold red]Faker library not installed. Run 'pip install faker' first.[/]")
241
+ return
242
+
243
+ import random
244
+ from datetime import datetime
245
+ import numpy as np
246
+
247
+ fake = Faker()
248
+
249
+ console.print("\n[bold yellow]🏭 INITIATING PRACTICE DATA ENGINE (ULLR GENERATE)...[/]")
250
+ time.sleep(0.5)
251
+
252
+ console.print("\n[bold cyan]Select an Industry Context:[/]")
253
+ console.print(" [bold green]1.[/] E-commerce (Sales, Customers, Products)")
254
+ console.print(" [bold blue]2.[/] Healthcare (Patients, Treatments, Costs)")
255
+ console.print(" [bold magenta]3.[/] Real Estate (Properties, Agents, Prices)")
256
+ console.print(" [bold yellow]4.[/] Finance & Banking (Loans, Credit Scores, Defaults)")
257
+ console.print(" [bold cyan]5.[/] Logistics & Supply Chain (Shipments, Warehouses, Delays)")
258
+ console.print(" [bold white]6.[/] HR & Payroll (Employees, Salaries, Attrition)")
259
+ console.print(" [bold red]7.[/] SaaS & Tech (Subscriptions, Churn, MRR)")
260
+
261
+ ind_choice = Prompt.ask("Select an option", choices=["1", "2", "3", "4", "5", "6", "7"], default="1")
262
+ rows_choice = Prompt.ask("How many rows of dirty practice data do you need?", default="1000")
263
+
264
+ try:
265
+ rows = int(rows_choice)
266
+ except:
267
+ rows = 1000
268
+
269
+ out_dir = get_default_dir()
270
+
271
+ with console.status(f"[cyan]● Fabricating {rows:,} rows of beautifully dirty data...[/]", spinner="dots"):
272
+ data = []
273
+ if ind_choice == "1":
274
+ industry = "ecommerce"
275
+ for i in range(rows):
276
+ data.append({
277
+ "Transaction_ID": fake.uuid4()[:8],
278
+ "Customer_Name": fake.name(),
279
+ "Customer_Age": random.randint(18, 75),
280
+ "Customer_Country": fake.country(),
281
+ "Purchase_Date": fake.date_between(start_date='-1y', end_date='today').strftime('%Y-%m-%d'),
282
+ "Product_Category": random.choice(["Electronics", "Clothing", "Home", "Sports", "Beauty", "Toys"]),
283
+ "Quantity": random.randint(1, 10),
284
+ "Unit_Price": round(random.uniform(10.0, 500.0), 2),
285
+ "Discount_Applied": random.choice([0.0, 5.0, 10.0, 25.0]),
286
+ "Payment_Method": random.choice(["Credit Card", "PayPal", "Debit Card", "Crypto"]),
287
+ "Shipping_Status": random.choice(["Delivered", "Shipped", "Processing", "Cancelled"]),
288
+ "Customer_Rating": random.randint(1, 5)
289
+ })
290
+ elif ind_choice == "2":
291
+ industry = "healthcare"
292
+ for i in range(rows):
293
+ data.append({
294
+ "Patient_ID": f"PT-{fake.random_int(min=1000, max=9999)}",
295
+ "Patient_Name": fake.name(),
296
+ "Gender": random.choice(["M", "F", "Other"]),
297
+ "Blood_Type": random.choice(["A+", "A-", "B+", "B-", "O+", "O-", "AB+", "AB-"]),
298
+ "Admission_Date": fake.date_between(start_date='-2y', end_date='today').strftime('%Y-%m-%d'),
299
+ "Admission_Type": random.choice(["Emergency", "Elective", "Transfer", "Maternity"]),
300
+ "Department": random.choice(["Cardiology", "Neurology", "Oncology", "Pediatrics", "Orthopedics", "ER"]),
301
+ "Insurance_Provider": random.choice(["BlueCross", "Aetna", "Cigna", "Medicare", "None"]),
302
+ "Length_of_Stay": random.randint(1, 30),
303
+ "Treatment_Cost": round(random.uniform(500.0, 15000.0), 2),
304
+ "Discharge_Status": random.choice(["Home", "Transferred", "Rehab", "Deceased"])
305
+ })
306
+ elif ind_choice == "3":
307
+ industry = "realestate"
308
+ for i in range(rows):
309
+ data.append({
310
+ "Property_ID": f"RE-{fake.random_int(min=100, max=999)}",
311
+ "Agent_Name": fake.name(),
312
+ "City": fake.city(),
313
+ "Zip_Code": fake.zipcode(),
314
+ "Listing_Date": fake.date_between(start_date='-6m', end_date='today').strftime('%Y-%m-%d'),
315
+ "Property_Type": random.choice(["House", "Condo", "Townhouse", "Commercial", "Multi-Family"]),
316
+ "Year_Built": random.randint(1950, 2023),
317
+ "Bedrooms": random.randint(1, 6),
318
+ "Bathrooms": random.randint(1, 5),
319
+ "Square_Feet": random.randint(800, 5000),
320
+ "Has_Pool": random.choice(["Yes", "No"]),
321
+ "HOA_Fees": round(random.uniform(0.0, 500.0), 2),
322
+ "Listing_Price": round(random.uniform(150000.0, 2000000.0), 2)
323
+ })
324
+ elif ind_choice == "4":
325
+ industry = "finance"
326
+ for i in range(rows):
327
+ data.append({
328
+ "Loan_ID": f"LN-{fake.random_int(min=10000, max=99999)}",
329
+ "Customer_Name": fake.name(),
330
+ "Credit_Score": random.randint(300, 850),
331
+ "Annual_Income": round(random.uniform(30000.0, 250000.0), 2),
332
+ "Loan_Amount": round(random.uniform(5000.0, 100000.0), 2),
333
+ "Interest_Rate": round(random.uniform(2.5, 15.0), 2),
334
+ "Loan_Term_Months": random.choice([12, 36, 60, 72]),
335
+ "Employment_Status": random.choice(["Employed", "Unemployed", "Self-Employed", "Retired"]),
336
+ "Default_Status": random.choice(["Yes", "No", "No", "No", "No"]),
337
+ "Approval_Date": fake.date_between(start_date='-3y', end_date='today').strftime('%Y-%m-%d')
338
+ })
339
+ elif ind_choice == "5":
340
+ industry = "logistics"
341
+ for i in range(rows):
342
+ data.append({
343
+ "Tracking_ID": f"TRK{fake.uuid4()[:8].upper()}",
344
+ "Warehouse_Location": fake.city(),
345
+ "Destination_City": fake.city(),
346
+ "Weight_kg": round(random.uniform(0.5, 150.0), 2),
347
+ "Carrier": random.choice(["FedEx", "UPS", "DHL", "USPS", "Prime"]),
348
+ "Shipping_Cost": round(random.uniform(5.0, 300.0), 2),
349
+ "Expected_Delivery": fake.date_between(start_date='-1y', end_date='today').strftime('%Y-%m-%d'),
350
+ "Status": random.choice(["On Time", "Delayed", "Lost", "Damaged"]),
351
+ "Fragile": random.choice(["Yes", "No"]),
352
+ "Distance_km": random.randint(10, 3000)
353
+ })
354
+ elif ind_choice == "6":
355
+ industry = "hr"
356
+ for i in range(rows):
357
+ data.append({
358
+ "Employee_ID": f"EMP-{fake.random_int(min=1000, max=9999)}",
359
+ "Employee_Name": fake.name(),
360
+ "Department": random.choice(["Engineering", "Sales", "Marketing", "HR", "Finance", "Legal"]),
361
+ "Job_Title": fake.job(),
362
+ "Hire_Date": fake.date_between(start_date='-10y', end_date='today').strftime('%Y-%m-%d'),
363
+ "Annual_Salary": round(random.uniform(40000.0, 200000.0), 2),
364
+ "Performance_Score": random.randint(1, 5),
365
+ "Remote_Status": random.choice(["Remote", "Hybrid", "Office"]),
366
+ "Years_at_Company": random.randint(0, 15),
367
+ "Attrition": random.choice(["Active", "Resigned", "Terminated", "Active", "Active"])
368
+ })
369
+ else:
370
+ industry = "saas"
371
+ for i in range(rows):
372
+ data.append({
373
+ "User_ID": f"USR-{fake.uuid4()[:6]}",
374
+ "Subscription_Tier": random.choice(["Free", "Pro", "Enterprise", "Pro"]),
375
+ "Signup_Date": fake.date_between(start_date='-4y', end_date='today').strftime('%Y-%m-%d'),
376
+ "Monthly_Revenue": random.choice([0.0, 29.99, 99.99, 499.99]),
377
+ "Last_Login_Date": fake.date_between(start_date='-1m', end_date='today').strftime('%Y-%m-%d'),
378
+ "Total_Logins": random.randint(1, 500),
379
+ "Support_Tickets": random.randint(0, 15),
380
+ "Churned": random.choice(["Yes", "No", "No", "No"]),
381
+ "Country": fake.country()
382
+ })
383
+
384
+ df = pd.DataFrame(data)
385
+
386
+ # --- INJECT DIRTY DATA ---
387
+ cols = list(df.columns)
388
+
389
+ # 1. Nulls (Missing Values)
390
+ for col in cols:
391
+ mask = np.random.rand(len(df)) < 0.05 # 5% missing
392
+ df.loc[mask, col] = np.nan
393
+
394
+ # 2. Invisible Spaces (Messy Text)
395
+ text_cols = [c for c in cols if 'Name' in c or 'Category' in c or 'Type' in c or 'Department' in c]
396
+ for col in text_cols:
397
+ mask = np.random.rand(len(df)) < 0.15 # 15% messy text
398
+ def add_spaces(x):
399
+ if pd.isna(x): return x
400
+ return (" " + str(x)) if random.random() > 0.5 else (str(x) + " ")
401
+
402
+ df.loc[mask, col] = df.loc[mask, col].apply(add_spaces)
403
+
404
+ # 3. Numbers Trapped as Text
405
+ num_cols = [c for c in cols if 'Price' in c or 'Cost' in c]
406
+ for col in num_cols:
407
+ df[col] = df[col].astype('object')
408
+ mask = np.random.rand(len(df)) < 0.20 # 20% trapped numbers
409
+ def add_currency(x):
410
+ if pd.isna(x): return x
411
+ return f"${x:,.2f}" if random.random() > 0.5 else f"£{x:,.2f}"
412
+
413
+ df.loc[mask, col] = df.loc[mask, col].apply(add_currency)
414
+
415
+ # 4. Ghost Column
416
+ df["Unnamed: 4"] = np.nan
417
+
418
+ # Save File
419
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
420
+ filename = f"{industry}_raw_{timestamp}.csv"
421
+
422
+ out_dir = get_default_dir()
423
+ out_path = os.path.join(out_dir, filename)
424
+
425
+ df.to_csv(out_path, index=False)
426
+ time.sleep(1)
427
+
428
+ console.print(f"\n[bold green]🎉 SUCCESS![/] {rows:,} rows of beautifully broken {industry} data generated.")
429
+ console.print(f"[cyan]Saved to: {out_path}[/]")
430
+
431
+ # --- DYNAMIC STAKEHOLDER BRIEF ---
432
+ real_cols = [col for col in df.columns if not str(col).lower().startswith('unnamed')]
433
+ numeric_cols = [c for c in df[real_cols].select_dtypes(include=['number', 'float64', 'int64']).columns.tolist() if 'id' not in c.lower() and 'zip' not in c.lower()]
434
+ # Text cols excluding specific names that usually act as unique IDs (like Patient_Name)
435
+ text_cols = [c for c in df[real_cols].select_dtypes(include=['object', 'string']).columns.tolist() if 'name' not in c.lower() and 'id' not in c.lower()]
436
+ date_cols = [col for col in real_cols if 'date' in col.lower() or 'time' in col.lower() or 'year' in col.lower()]
437
+
438
+ console.print("\n[bold magenta]👔 STAKEHOLDER BRIEF: SYSTEM REQUIREMENTS[/]")
439
+
440
+ if industry == "ecommerce":
441
+ console.print("[bold italic white]\"You are the Lead Data Analyst for a global retail brand. The Q4 board meeting is tomorrow. The VP of Sales just handed you this raw transaction dump. She needs to know which product categories are driving our revenue, if our discounts are actually working, and how our shipping status is impacting customer satisfaction.\"[/]\n")
442
+ elif industry == "healthcare":
443
+ console.print("[bold italic white]\"You are the Operations Analyst for Metro General Hospital. The Chief Medical Officer just handed you this month's raw admission logs. She needs to know which departments are facing the most bed shortages (length of stay), what our most expensive treatments are, and the breakdown of our patient insurance providers.\"[/]\n")
444
+ elif industry == "realestate":
445
+ console.print("[bold italic white]\"You are the Portfolio Analyst for a luxury real estate brokerage. The Managing Broker wants a complete breakdown of the current housing market. They need you to identify which cities are selling the most expensive properties, how HOA fees impact listing prices, and which agents are moving the most volume.\"[/]\n")
446
+ elif industry == "finance":
447
+ console.print("[bold italic white]\"You are a Risk Analyst at a global bank. The VP of Lending wants to know which demographic has the highest default rates, the average credit score of approved loans, and how interest rates correlate with employment status.\"[/]\n")
448
+ elif industry == "logistics":
449
+ console.print("[bold italic white]\"You are a Supply Chain Analyst for a massive logistics network. The Director of Operations needs you to find out which warehouses are causing the most delays, which carriers are the most expensive, and the overall lost package rate.\"[/]\n")
450
+ elif industry == "hr":
451
+ console.print("[bold italic white]\"You are an HR Analytics Partner. The Head of HR is worried about high turnover. They need a dashboard showing the attrition rate by department, the average salary across job titles, and if remote workers have higher performance scores.\"[/]\n")
452
+ else:
453
+ console.print("[bold italic white]\"You are a Product Analyst at a fast-growing tech startup. The CEO is prepping for a VC pitch. She needs to know our exact Monthly Recurring Revenue (MRR), the churn rate broken down by subscription tier, and if users who raise support tickets are more likely to cancel.\"[/]\n")
454
+
455
+ console.print("[bold cyan]KPIs (The Top-Level Cards)[/]")
456
+ if numeric_cols:
457
+ for num_col in numeric_cols[:2]:
458
+ console.print(f" * Total {num_col.replace('_', ' ')}")
459
+ console.print(f" * Avg. {num_col.replace('_', ' ')}")
460
+ else:
461
+ console.print(" * Total Records")
462
+
463
+ console.print("\n[bold cyan]Business Questions (The Charts & Visuals)[/]")
464
+ if date_cols and numeric_cols:
465
+ console.print(f" * Time-Series Trend: How is {numeric_cols[0].replace('_', ' ')} trending based on {date_cols[0].replace('_', ' ')}? (Line Chart)")
466
+ if text_cols and numeric_cols:
467
+ console.print(f" * Financial/Metric Breakdown: Which {text_cols[0].replace('_', ' ')} drives the highest {numeric_cols[0].replace('_', ' ')}? (Bar Chart)")
468
+ if text_cols:
469
+ console.print(f" * Volume Breakdown: What is the distribution of total volume by {text_cols[0].replace('_', ' ')}? (Donut Chart)")
470
+ if len(text_cols) > 1 and numeric_cols:
471
+ console.print(f" * Secondary Ranking: Compare {text_cols[1].replace('_', ' ')} by {numeric_cols[-1].replace('_', ' ')}? (Bar Chart)")
472
+
473
+ console.print("\n[bold yellow]Your training mission is ready. Clean the data and build the Engine to answer these exact questions![/]")
474
+
475
+
476
+
477
+ def prompt_for_file(action_name):
478
+ from rich.prompt import Prompt
479
+ import glob
480
+
481
+ cwd = os.getcwd()
482
+ files = []
483
+ for ext in ['*.csv', '*.xlsx', '*.xls']:
484
+ files.extend(glob.glob(os.path.join(cwd, ext)))
485
+
486
+ if files:
487
+ console.print(f"\n[bold cyan]📂 Found data files in current folder:[/]")
488
+ for i, f in enumerate(files):
489
+ console.print(f" [bold green]{i+1}.[/] {os.path.basename(f)}")
490
+ console.print(" [bold yellow]0.[/] Type a manual filename or path instead")
491
+
492
+ choices = [str(i) for i in range(len(files) + 1)]
493
+ choice = Prompt.ask(f"\nSelect a file to {action_name}", choices=choices, default="1")
494
+
495
+ if choice != "0":
496
+ return files[int(choice)-1]
497
+
498
+ return Prompt.ask(f"\n[bold yellow]Enter the filename or path to {action_name} (e.g., data.csv)[/]").strip()
499
+
500
+ def interactive_mode():
501
+
502
+ from rich.prompt import Prompt
503
+
504
+ print_banner()
505
+ console.print("[bold green]Welcome to the Ullr Data Engine![/]\n")
506
+
507
+ while True:
508
+ console.print("[bold cyan]What would you like to do?[/]")
509
+ console.print(" [bold green]1.[/] Audit Data")
510
+ console.print(" [bold yellow]2.[/] Generate Data")
511
+ console.print(" [bold dim]3.[/] Help Menu")
512
+ console.print(" [bold red]4.[/] Exit")
513
+
514
+ choice = Prompt.ask("\nSelect an option", choices=["1", "2", "3", "4"], default="1")
515
+
516
+ if choice == "4":
517
+ console.print("[yellow]Goodbye![/]")
518
+ break
519
+ elif choice == "3":
520
+ console.print("\n[bold underline]Ullr Help Menu[/]")
521
+ console.print("[bold green]Audit Data:[/] Scans a raw CSV/Excel file for missing values, duplicates, and formatting errors without changing the file.")
522
+ console.print("[bold yellow]Generate Data:[/] Creates synthetic, dirty practice datasets with a Stakeholder Brief for you to practice cleaning.")
523
+ elif choice == "2":
524
+ generate_data()
525
+ elif choice == "1":
526
+ filepath = prompt_for_file("audit")
527
+ if filepath:
528
+ audit_data(filepath)
529
+
530
+ console.print("\n" + "-"*50 + "\n")
531
+
532
+ def cli():
533
+ parser = argparse.ArgumentParser(description="Ullr: The Offline Data Auditor")
534
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
535
+
536
+ audit_parser = subparsers.add_parser("audit", help="Check raw data for flaws (NULLs, duplicates).")
537
+ audit_parser.add_argument("file", help="Path to the CSV/Excel file")
538
+
539
+ generate_parser = subparsers.add_parser("generate", help="Generate synthetic dirty datasets for Excel practice.")
540
+
541
+ args = parser.parse_args()
542
+
543
+ if not args.command:
544
+ interactive_mode()
545
+ sys.exit(0)
546
+
547
+ print_banner()
548
+ if args.command == "audit":
549
+ audit_data(args.file)
550
+ elif args.command == "generate":
551
+ generate_data()
552
+
553
+ if __name__ == "__main__":
554
+ cli()
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ullr-data-cli
3
- Version: 1.0.2
4
- Summary: An offline, AI-free Data Analytics and Dashboard Auditing CLI
3
+ Version: 1.0.3
4
+ Summary: A purely native Practice Engine for aspiring Data Analysts to generate and audit datasets
5
5
  Home-page: https://github.com/jshlydnzl/ullr-data-cli
6
6
  Author: jshlydnzl
7
7
  Author-email: jshlydnzl@users.noreply.github.com
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
15
15
  Requires-Dist: rich
16
16
  Requires-Dist: pandas
17
17
  Requires-Dist: openpyxl
18
+ Requires-Dist: faker
18
19
  Dynamic: author
19
20
  Dynamic: author-email
20
21
  Dynamic: classifier
@@ -1,3 +1,4 @@
1
1
  rich
2
2
  pandas
3
3
  openpyxl
4
+ faker