├── labrat ├── guis │ └── __init__.py ├── genetics │ ├── __init__.py │ ├── codons.json │ ├── dna_analysis.py │ └── protein_analysis.py ├── graphpad │ ├── __init__.py │ └── scripts.py ├── notebook │ └── __init__.py ├── project │ ├── __init__.py │ └── projectmanager.py ├── filemanager │ ├── __init__.py │ ├── archive.py │ └── organize.py ├── inventory │ └── lab_inventory_examples.xlsx ├── math │ ├── __init__.py │ ├── README.md │ └── functions.py ├── __init__.py ├── utils.py └── cli.py ├── _config.yml ├── requirements.txt ├── MANIFEST.IN ├── setup.cfg ├── setup.py ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── test-build.yml │ └── jekyll-gh-pages.yml ├── .readthedocs.yaml ├── .travis.yml ├── docs ├── api.md ├── cli.md ├── installation.md └── index.md ├── LICENSE ├── mkdocs.yml ├── CONTRIBUTING.md ├── CHANGELOG.md ├── tests ├── test_archiver.py ├── test_file_organizer.py ├── test_project_manager.py ├── test_protein_analysis.py ├── test_math_functions.py └── test_cli.py ├── pyproject.toml ├── .gitignore └── README.md /labrat/guis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labrat/genetics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labrat/graphpad/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labrat/graphpad/scripts.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /labrat/notebook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /labrat/project/__init__.py: -------------------------------------------------------------------------------- 1 | from .projectmanager import ProjectManager 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | logzero>=1.3.1 2 | cookiecutter>=1.5.1 3 | click>=6.7 4 | jinja2-time>=0.2.0 -------------------------------------------------------------------------------- /MANIFEST.IN: -------------------------------------------------------------------------------- 1 | include tests/ 2 | include labrat/genetics/codons.json 3 | recursive-include labrat *.json -------------------------------------------------------------------------------- /labrat/filemanager/__init__.py: -------------------------------------------------------------------------------- 1 | from .archive import Archiver 2 | from .organize import FileOrganizer -------------------------------------------------------------------------------- /labrat/inventory/lab_inventory_examples.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdhutchins/labrat/HEAD/labrat/inventory/lab_inventory_examples.xlsx -------------------------------------------------------------------------------- /labrat/math/__init__.py: -------------------------------------------------------------------------------- 1 | from .functions import (dilute_stock, transmittance_to_absorbance, 2 | calculate_molarity, refractive_index_prism) 3 | -------------------------------------------------------------------------------- /labrat/__init__.py: -------------------------------------------------------------------------------- 1 | # Package Global Variables 2 | _DATEFMT1 = '%a %b %d %I:%M:%S %p %Y' # Used to add as a date 3 | _DATEFMT2 = '%m-%d-%Y_%I-%M-%S-%p' # Used to append to archives 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | doctests = True 4 | show-source = True 5 | ignore = 6 | exclude = 7 | .git 8 | libs 9 | docs 10 | tests 11 | __init__.py -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Minimal setup.py for backwards compatibility. 3 | 4 | All package configuration is now in pyproject.toml. 5 | This file is kept for backwards compatibility with older tools. 6 | """ 7 | 8 | from setuptools import setup 9 | 10 | # All configuration is in pyproject.toml 11 | setup() 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### This pull request addresses issue # 2 | 3 | ### Features Added 4 | 5 | - Feature added - *details* 6 | 7 | ### Bugs Fixed 8 | 9 | - Bug fixed - *details* 10 | 11 | ### Features Deprecated 12 | 13 | - Feature deprecated - *details* 14 | 15 | :warning: Delete this line! Please request a review from @sdhutchins! Delete any unused heading or line. 16 | -------------------------------------------------------------------------------- /labrat/math/README.md: -------------------------------------------------------------------------------- 1 | ## math 2 | This labrat module includes common lab (molecular and otherwise) functions. 3 | 4 | ### Examples 5 | 6 | *Dilute a stock concentration* 7 | ```python 8 | from labrat.math import dilute_stock 9 | 10 | # Get the final concentration 11 | dilute_stock(100, 2, **{'vF': 4}) 12 | Out[71]: 50.0 13 | 14 | # Get the final concentration 15 | dilute_stock(100, 2, **{'cF': 50}) 16 | Out[71]: 4.0 17 | ``` -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-24.04" 5 | tools: 6 | python: "3.11" 7 | jobs: 8 | pre_install: 9 | - pip install --upgrade pip 10 | - pip install -r requirements.txt 11 | - pip install mkdocs>=1.5.0 mkdocs-material>=9.0.0 mkdocs-click>=0.8.0 "mkdocstrings[python]>=0.23.0" pymdown-extensions>=10.0.0 12 | - pip install -e . 13 | 14 | mkdocs: 15 | configuration: mkdocs.yml 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: focal # Use Ubuntu 20.04 for Python 3.10+ support 3 | cache: pip 4 | 5 | python: 6 | - "3.9" 7 | - "3.10" 8 | - "3.11" 9 | 10 | notifications: 11 | email: false 12 | 13 | # Install dependencies 14 | install: 15 | - pip install --upgrade pip setuptools wheel 16 | - pip install -e . # Install the package in editable mode 17 | - pip install -r requirements.txt 18 | - pip install pytest pytest-cov # Ensure pytest and coverage are installed 19 | 20 | # Run tests with pytest and generate a coverage report 21 | script: 22 | - pytest --cov=labrat tests/ -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Math Functions 4 | 5 | ::: labrat.math.functions 6 | options: 7 | show_root_heading: true 8 | show_source: false 9 | 10 | ## Genetics 11 | 12 | ::: labrat.genetics.dna_analysis 13 | options: 14 | show_root_heading: true 15 | show_source: false 16 | 17 | ::: labrat.genetics.protein_analysis 18 | options: 19 | show_root_heading: true 20 | show_source: false 21 | 22 | ## Project Management 23 | 24 | ::: labrat.project.projectmanager 25 | options: 26 | show_root_heading: true 27 | show_source: false 28 | 29 | ## File Management 30 | 31 | ::: labrat.filemanager.archive 32 | options: 33 | show_root_heading: true 34 | show_source: false 35 | 36 | ::: labrat.filemanager.organize 37 | options: 38 | show_root_heading: true 39 | show_source: false 40 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # CLI Reference 2 | 3 | labrat provides a command-line interface for common tasks. 4 | 5 | ## Commands 6 | 7 | ### Project Management 8 | 9 | ```bash 10 | # Create a new project 11 | labrat project new --type --name --path 12 | 13 | # List all projects 14 | labrat project list 15 | 16 | # Delete a project 17 | labrat project delete --path 18 | ``` 19 | 20 | ### Archiving 21 | 22 | ```bash 23 | labrat archive --source --destination --name 24 | ``` 25 | 26 | ### File Organization 27 | 28 | ```bash 29 | # Organize scientific files 30 | labrat organize --science 31 | 32 | # Organize by keyword 33 | labrat organize --keyword 34 | 35 | # Organize all file types 36 | labrat organize --all 37 | ``` 38 | 39 | For detailed help on any command: 40 | 41 | ```bash 42 | labrat --help 43 | labrat --help 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Requirements 4 | 5 | - Python 3.8 or higher 6 | - pip 7 | 8 | ## Install from Source 9 | 10 | 1. Clone the repository: 11 | 12 | ```bash 13 | git clone https://github.com/sdhutchins/labrat.git 14 | cd labrat 15 | ``` 16 | 17 | 2. Install the package: 18 | 19 | ```bash 20 | pip install . 21 | ``` 22 | 23 | For development mode: 24 | 25 | ```bash 26 | pip install -e . 27 | ``` 28 | 29 | ## Install Dependencies 30 | 31 | Install all required dependencies: 32 | 33 | ```bash 34 | pip install -r requirements.txt 35 | ``` 36 | 37 | ## Install Documentation Dependencies 38 | 39 | To build and serve the documentation locally: 40 | 41 | ```bash 42 | pip install -e ".[docs]" 43 | ``` 44 | 45 | Or install manually: 46 | 47 | ```bash 48 | pip install mkdocs-material mkdocs-click mkdocstrings[python] pymdown-extensions 49 | ``` 50 | 51 | ## Verify Installation 52 | 53 | Run the tests to verify everything is working: 54 | 55 | ```bash 56 | pytest tests/ 57 | ``` 58 | -------------------------------------------------------------------------------- /labrat/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | from pathlib import Path 4 | 5 | 6 | def get_labrat_dir() -> Path: 7 | """ 8 | Get the .labrat directory path in the user's home directory. 9 | 10 | Creates the directory if it doesn't exist. This directory is used for 11 | storing logs, archives, and other labrat-related files. 12 | 13 | Returns: 14 | Path: Path to the .labrat directory in the user's home directory. 15 | 16 | Example: 17 | >>> labrat_dir = get_labrat_dir() 18 | >>> log_file = labrat_dir / "archive.log" 19 | """ 20 | labrat_dir = Path.home() / ".labrat" 21 | # Create directory if it doesn't exist (cross-platform compatible) 22 | labrat_dir.mkdir(exist_ok=True) 23 | return labrat_dir 24 | 25 | 26 | def import_json(json_file): 27 | """Import json and convert it to a dictionary.""" 28 | with open(json_file) as jsonfile: 29 | # `json.loads` parses a string in json format 30 | file_dict = json.load(jsonfile) 31 | return file_dict 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shaurita D. Hutchins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /labrat/genetics/codons.json: -------------------------------------------------------------------------------- 1 | { 2 | "CODONS": { 3 | "ATA": "I", 4 | "ATC": "I", 5 | "ATT": "I", 6 | "ATG": "M", 7 | "ACA": "T", 8 | "ACC": "T", 9 | "ACG": "T", 10 | "ACT": "T", 11 | "AAC": "N", 12 | "AAT": "N", 13 | "AAA": "K", 14 | "AAG": "K", 15 | "AGC": "S", 16 | "AGT": "S", 17 | "AGA": "R", 18 | "AGG": "R", 19 | "CTA": "L", 20 | "CTC": "L", 21 | "CTG": "L", 22 | "CTT": "L", 23 | "CCA": "P", 24 | "CCC": "P", 25 | "CCG": "P", 26 | "CCT": "P", 27 | "CAC": "H", 28 | "CAT": "H", 29 | "CAA": "Q", 30 | "CAG": "Q", 31 | "CGA": "R", 32 | "CGC": "R", 33 | "CGG": "R", 34 | "CGT": "R", 35 | "GTA": "V", 36 | "GTC": "V", 37 | "GTG": "V", 38 | "GTT": "V", 39 | "GCA": "A", 40 | "GCC": "A", 41 | "GCG": "A", 42 | "GCT": "A", 43 | "GAC": "D", 44 | "GAT": "D", 45 | "GAA": "E", 46 | "GAG": "E", 47 | "GGA": "G", 48 | "GGC": "G", 49 | "GGG": "G", 50 | "GGT": "G", 51 | "TCA": "S", 52 | "TCC": "S", 53 | "TCG": "S", 54 | "TCT": "S", 55 | "TTC": "F", 56 | "TTT": "F", 57 | "TTA": "L", 58 | "TTG": "L", 59 | "TAC": "Y", 60 | "TAT": "Y", 61 | "TAA": "_", 62 | "TAG": "_", 63 | "TGC": "C", 64 | "TGT": "C", 65 | "TGA": "_", 66 | "TGG": "W" 67 | } 68 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # labrat 2 | 3 | A basic science lab framework aimed at reproducibility and lab management. 4 | 5 | ## Features 6 | 7 | - Math functions for dilutions, molarity calculations, and more 8 | - Command-line tools for archiving and organizing files 9 | - Project management for computational biology workflows 10 | - DNA and protein analysis utilities 11 | 12 | ## Quick Start 13 | 14 | Install labrat: 15 | 16 | ```bash 17 | pip install . 18 | ``` 19 | 20 | Or for development: 21 | 22 | ```bash 23 | pip install -e . 24 | ``` 25 | 26 | ## Usage 27 | 28 | ### Command Line 29 | 30 | ```bash 31 | # Create a new project 32 | labrat project new --type computational-biology --name "My Project" 33 | 34 | # List projects 35 | labrat project list 36 | 37 | # Archive a directory 38 | labrat archive --source /path/to/source --destination /path/to/archive --name project_name 39 | 40 | # Organize files 41 | labrat organize --science --all 42 | ``` 43 | 44 | ### Python API 45 | 46 | ```python 47 | from labrat.math import dilute_stock 48 | 49 | # Calculate final concentration 50 | final_conc = dilute_stock(100, 2, vF=4) 51 | ``` 52 | 53 | ## Documentation 54 | 55 | - [Installation Guide](installation.md) 56 | - [CLI Reference](cli.md) 57 | - [API Reference](api.md) 58 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: labrat 2 | site_author: Shaurita Hutchins 3 | copyright: Copyright © 2025 Shaurita Hutchins 4 | site_url: !ENV READTHEDOCS_CANONICAL_URL 5 | 6 | nav: 7 | - Home: index.md 8 | - Installation: installation.md 9 | - CLI Reference: cli.md 10 | - API Reference: api.md 11 | 12 | theme: 13 | name: material 14 | features: 15 | - navigation.expand 16 | palette: 17 | - media: "(prefers-color-scheme: dark)" 18 | scheme: slate 19 | primary: blue 20 | accent: blue 21 | toggle: 22 | icon: material/weather-night 23 | name: Switch to light mode 24 | - media: "(prefers-color-scheme: light)" 25 | scheme: default 26 | primary: blue 27 | accent: blue 28 | toggle: 29 | icon: material/weather-sunny 30 | name: Switch to dark mode 31 | 32 | repo_url: https://github.com/sdhutchins/labrat 33 | repo_name: sdhutchins/labrat 34 | 35 | markdown_extensions: 36 | - admonition 37 | - smarty 38 | - attr_list 39 | - mkdocs-click 40 | - pymdownx.superfences 41 | - pymdownx.highlight: 42 | anchor_linenums: true 43 | pygments_lang_class: true 44 | 45 | plugins: 46 | - search 47 | - mkdocstrings: 48 | handlers: 49 | python: 50 | paths: [labrat] 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | Thanks for your desire to contribute to this project! 4 | 5 | ## Preparing your fork 6 | 7 | 1. Hit 'fork' on Github, creating e.g. `yourname/labrat`. 8 | 2. Clone your project: `git clone https://github.com/yourname/labrat.git`. 9 | 3. Create a branch: `cd labrat; git checkout -b new-feature`. 10 | 11 | ## Development Mode Installation 12 | 13 | 1. Change to the project repository on your machine: `cd labrat` 14 | 2. Install in development mode: `pip install -e .` 15 | 16 | ## Making your changes 17 | 18 | 1. Add your contributions and please adhere to [Google's python style guide](https://github.com/google/styleguide/blob/gh-pages/pyguide.md). 19 | 2. Run tests and make sure they pass. (I intend to use unittest.) 20 | 3. Commit your changes: `git commit -m "Added new feature"` and please be descriptive with git commits! 21 | 22 | ## Creating Pull Requests 23 | 24 | 1. Push your commit to get it back up to your fork: `git push origin HEAD`. 25 | 2. Visit Github, click the handy "Pull request" button that it will make upon noticing your new branch. 26 | 3. In the description field, write down issue number (if submitting code fixing an existing issue) or describe the issue + your fix (if submitting a wholly new bugfix). 27 | 4. Hit 'submit' and ask for `sdhutchins` to review, and I will get to you as soon as I can. -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.0] - 2025-11-21 9 | 10 | ### Added 11 | - Command-line interface (CLI) with comprehensive testing infrastructure 12 | - Protein analysis functionality with test coverage 13 | - Project management system with `.labrat` folder support for project metadata 14 | - Documentation structure including API, CLI, installation, and index pages 15 | - Test documentation for contributors 16 | - Comprehensive test suite covering: 17 | - CLI functionality 18 | - Math functions 19 | - Protein analysis 20 | - Archiver 21 | - File organizer 22 | - Project manager 23 | - `jinja2-time` dependency for template functionality 24 | 25 | ### Changed 26 | - PyPI package name changed from `labrat` to `pylabrat` to avoid naming conflicts 27 | - Moved pull request template to `.github` folder for better organization 28 | 29 | ### Fixed 30 | - Logic error in `dilute_stock` function 31 | - Math function test failures 32 | - Protein analysis implementation issues 33 | - Various errors throughout the codebase 34 | - Missing dependency declarations 35 | 36 | ### Improved 37 | - Code comments and documentation throughout 38 | - Test coverage and reliability 39 | -------------------------------------------------------------------------------- /.github/workflows/test-build.yml: -------------------------------------------------------------------------------- 1 | name: Test Package Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12"] 15 | 16 | steps: 17 | - uses: actions/checkout@v5 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install build dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install build pytest pytest-cov 28 | 29 | - name: Install package dependencies 30 | run: | 31 | pip install -r requirements.txt 32 | 33 | - name: Install package in editable mode 34 | run: | 35 | pip install -e . 36 | 37 | - name: Build package 38 | run: | 39 | python -m build 40 | 41 | - name: Run tests with coverage 42 | run: | 43 | pytest tests/ -v --cov=labrat --cov-branch --cov-report=xml --cov-report=term 44 | 45 | - name: Upload coverage reports to Codecov 46 | uses: codecov/codecov-action@v5 47 | with: 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | file: ./coverage.xml 50 | flags: unittests 51 | name: codecov-umbrella 52 | fail_ci_if_error: false 53 | slug: sdhutchins/labrat 54 | -------------------------------------------------------------------------------- /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["master"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build job 26 | build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | - name: Build with Jekyll 34 | uses: actions/jekyll-build-pages@v1 35 | with: 36 | source: ./ 37 | destination: ./_site 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | 41 | # Deployment job 42 | deploy: 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | runs-on: ubuntu-latest 47 | needs: build 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /tests/test_archiver.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | from labrat.filemanager import Archiver 4 | import shutil 5 | 6 | class TestArchiver(unittest.TestCase): 7 | def setUp(self): 8 | """ 9 | Set up temporary directories and files for testing. 10 | """ 11 | self.test_dir = Path("./test_archiver") 12 | self.source_dir = self.test_dir / "source" 13 | self.archive_base_dir = self.test_dir / "archives" 14 | 15 | self.source_dir.mkdir(parents=True, exist_ok=True) 16 | (self.source_dir / "file1.txt").write_text("This is a test file.") 17 | (self.source_dir / "file2.txt").write_text("Another test file.") 18 | 19 | self.archive_base_dir.mkdir(parents=True, exist_ok=True) 20 | 21 | def tearDown(self): 22 | """ 23 | Clean up temporary files and directories. 24 | """ 25 | if self.test_dir.exists(): 26 | shutil.rmtree(self.test_dir) 27 | 28 | def test_archive(self): 29 | """ 30 | Test archiving functionality. 31 | """ 32 | archive_dir = Archiver.get_archive_dir(self.archive_base_dir, "test_project") 33 | archiver = Archiver(source_dir=self.source_dir, archive_dir=archive_dir) 34 | zip_path = archiver.archive() 35 | 36 | self.assertTrue(archive_dir.exists(), "Archive directory does not exist.") 37 | self.assertTrue((archive_dir / "file1.txt").exists(), "File1.txt was not archived.") 38 | self.assertTrue((archive_dir / "file2.txt").exists(), "File2.txt was not archived.") 39 | self.assertTrue(Path(zip_path).exists(), "Zip file was not created.") 40 | self.assertTrue(zip_path.endswith(".zip"), "Zip file does not have the correct extension.") 41 | 42 | if __name__ == "__main__": 43 | unittest.main() 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 77.0.3", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pylabrat" 7 | version = "0.1" 8 | description = "A package of helpful guis and functions to improve reproducibility for genetics/psychiatry related labs." 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | license = "MIT" 12 | license-files = ["LICENSE"] 13 | authors = [ 14 | {name = "Shaurita Hutchins", email = "shaurita.d.hutchins@gmail.com"} 15 | ] 16 | keywords = ["science", "lab", "genetics", "math", "filemanagement"] 17 | classifiers = [ 18 | "Development Status :: 3 - Alpha", 19 | "Programming Language :: Python :: 3", 20 | "Operating System :: POSIX :: Linux", 21 | "Operating System :: Unix", 22 | "Natural Language :: English", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | ] 28 | 29 | dependencies = [ 30 | "cookiecutter>=1.5.1", 31 | "logzero>=1.3.1", 32 | "exmemo>=0.1.0", 33 | "click>=6.7", 34 | "jinja2-time>=0.2.0", 35 | ] 36 | 37 | [project.optional-dependencies] 38 | docs = [ 39 | "mkdocs>=1.5.0", 40 | "mkdocs-material>=9.0.0", 41 | "mkdocs-click>=0.8.0", 42 | "mkdocstrings[python]>=0.23.0", 43 | "pymdown-extensions>=10.0.0", 44 | ] 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/sdhutchins/labrat" 48 | Documentation = "https://labrat.readthedocs.io/en/latest/" 49 | Releases = "https://github.com/sdhutchins/labrat/releases" 50 | Issues = "https://github.com/sdhutchins/labrat/issues" 51 | 52 | [project.scripts] 53 | labrat = "labrat.cli:main" 54 | 55 | [tool.setuptools] 56 | packages = {find = {}} 57 | include-package-data = true 58 | zip-safe = false 59 | 60 | [tool.setuptools.package-data] 61 | "labrat.genetics" = ["codons.json"] 62 | -------------------------------------------------------------------------------- /labrat/genetics/dna_analysis.py: -------------------------------------------------------------------------------- 1 | """DNA Analysis functions.""" 2 | 3 | from typing import Dict 4 | 5 | 6 | def atgc_content(dna_sequence: str) -> Dict[str, int]: 7 | """ 8 | Calculate the nucleotide composition of a DNA sequence. 9 | 10 | Counts the occurrences of each nucleotide (A, T, G, C) in the sequence. 11 | Non-standard nucleotides are ignored in the count. 12 | 13 | Args: 14 | dna_sequence (str): The DNA sequence to analyze. Case-insensitive. 15 | 16 | Returns: 17 | dict: A dictionary with keys 'A', 'T', 'G', 'C' and their counts as values. 18 | 19 | Example: 20 | >>> atgc_content("ATGCGAT") 21 | {'A': 2, 'T': 2, 'G': 2, 'C': 1} 22 | """ 23 | if not isinstance(dna_sequence, str): 24 | raise TypeError(f"Expected str, got {type(dna_sequence).__name__}") 25 | 26 | atgc_dict = {'A': 0, 'T': 0, 'G': 0, 'C': 0} 27 | dna_sequence_upper = dna_sequence.upper() 28 | 29 | for nucleotide in dna_sequence_upper: 30 | if nucleotide in atgc_dict: 31 | atgc_dict[nucleotide] += 1 32 | 33 | return atgc_dict 34 | 35 | 36 | def complementary_dna(dna_sequence: str) -> str: 37 | """ 38 | Create the complementary DNA sequence. 39 | 40 | Generates the complementary strand by replacing each nucleotide with its 41 | complement: A↔T and G↔C. Non-standard nucleotides are replaced with 'X'. 42 | 43 | Args: 44 | dna_sequence (str): The DNA sequence to complement. Case-insensitive. 45 | 46 | Returns: 47 | str: The complementary DNA sequence as a string. 48 | 49 | Example: 50 | >>> complementary_dna("ATGC") 51 | 'TACG' 52 | >>> complementary_dna("ATGN") 53 | 'TACGX' 54 | """ 55 | if not isinstance(dna_sequence, str): 56 | raise TypeError(f"Expected str, got {type(dna_sequence).__name__}") 57 | 58 | complementary_map = {'A': 'T', 'T': 'A', 'G': 'C', 'C': 'G'} 59 | dna_sequence_upper = dna_sequence.upper() 60 | cdna_list = [] 61 | 62 | for nucleotide in dna_sequence_upper: 63 | cdna_list.append(complementary_map.get(nucleotide, 'X')) 64 | 65 | return ''.join(cdna_list) 66 | -------------------------------------------------------------------------------- /tests/test_file_organizer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | from labrat.filemanager import FileOrganizer # type: ignore 4 | import shutil 5 | import datetime 6 | 7 | class TestFileOrganizer(unittest.TestCase): 8 | def setUp(self): 9 | """ 10 | Set up temporary directories and files for testing. 11 | """ 12 | self.test_dir = Path("./test_file_organizer") 13 | self.test_dir.mkdir(parents=True, exist_ok=True) 14 | 15 | self.downloads_dir = self.test_dir / "Downloads" 16 | self.documents_dir = self.test_dir / "Documents" 17 | self.pictures_dir = self.test_dir / "Pictures" 18 | 19 | # Simulate user directories 20 | self.downloads_dir.mkdir(parents=True, exist_ok=True) 21 | self.documents_dir.mkdir(parents=True, exist_ok=True) 22 | 23 | # Create test files 24 | (self.downloads_dir / "test_image.jpg").write_text("This is an image file.") 25 | (self.documents_dir / "test_doc.pdf").write_text("This is a PDF file.") 26 | 27 | # Initialize the organizer with test paths 28 | self.organizer = FileOrganizer() 29 | self.organizer.downloads_dir = self.downloads_dir 30 | self.organizer.documents_dir = self.documents_dir 31 | self.organizer.pictures_dir = self.pictures_dir 32 | 33 | def tearDown(self): 34 | """ 35 | Clean up temporary files and directories. 36 | """ 37 | if self.test_dir.exists(): 38 | shutil.rmtree(self.test_dir) 39 | 40 | def test_organize_files(self): 41 | """ 42 | Test file organization for pictures. 43 | """ 44 | self.organizer.organize_files() 45 | self.assertTrue((self.pictures_dir / "test_image.jpg").exists()) 46 | self.assertFalse((self.downloads_dir / "test_image.jpg").exists()) 47 | 48 | def test_organize_documents(self): 49 | """ 50 | Test file organization for documents. 51 | """ 52 | self.organizer.organize_documents() 53 | 54 | # Determine the year for organization 55 | year = datetime.datetime.now().year 56 | pdf_dir = self.documents_dir / "PDFs" / str(year) 57 | 58 | # Check if the file was moved correctly 59 | self.assertTrue((pdf_dir / "test_doc.pdf").exists()) 60 | self.assertFalse((self.documents_dir / "test_doc.pdf").exists()) 61 | 62 | def test_move_specific_files(self): 63 | """ 64 | Test moving files containing specific keywords. 65 | """ 66 | specific_dir = self.organizer.specific_dir 67 | (self.downloads_dir / "shaurita_test.txt").write_text("Specific file for testing.") 68 | self.organizer.move_specific_files(keyword="shaurita") 69 | self.assertTrue((specific_dir / "shaurita_test.txt").exists()) 70 | 71 | if __name__ == "__main__": 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /tests/test_project_manager.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | import json 4 | import shutil 5 | import tempfile 6 | from unittest.mock import patch 7 | from labrat.project import ProjectManager 8 | 9 | class TestProjectManager(unittest.TestCase): 10 | def setUp(self): 11 | """ 12 | Set up test environment. 13 | """ 14 | self.test_dir = Path("./test_project_manager") 15 | self.project_path = self.test_dir / "test_project" 16 | self.archive_dir = self.test_dir / "archives" 17 | 18 | # Create a temporary directory for .labrat config to avoid modifying user's real config 19 | self.temp_labrat_dir = Path(tempfile.mkdtemp(prefix="labrat_test_")) 20 | self.labrat_file = self.temp_labrat_dir / "config.json" 21 | 22 | self.test_dir.mkdir(parents=True, exist_ok=True) 23 | self.archive_dir.mkdir(parents=True, exist_ok=True) 24 | 25 | # Mock get_labrat_dir to return our temporary directory for testing 26 | # This ensures tests don't modify the user's actual .labrat directory 27 | self.labrat_dir_patcher = patch('labrat.project.projectmanager.get_labrat_dir') 28 | self.mock_get_labrat_dir = self.labrat_dir_patcher.start() 29 | self.mock_get_labrat_dir.return_value = self.temp_labrat_dir 30 | 31 | self.manager = ProjectManager(username="test_user") 32 | 33 | def tearDown(self): 34 | """ 35 | Clean up test environment. 36 | """ 37 | # Stop the mock 38 | self.labrat_dir_patcher.stop() 39 | 40 | # Clean up test directories 41 | if self.test_dir.exists(): 42 | shutil.rmtree(self.test_dir) 43 | # Clean up temporary .labrat directory used for testing 44 | if self.temp_labrat_dir.exists(): 45 | shutil.rmtree(self.temp_labrat_dir) 46 | 47 | def test_new_project(self): 48 | """ 49 | Test creation of a new project. 50 | """ 51 | self.manager.new_project( 52 | project_type="computational-biology", 53 | project_name="Test Project", 54 | project_path=self.project_path, 55 | description="A test project" 56 | ) 57 | self.assertTrue(self.project_path.exists(), "Project directory was not created.") 58 | with self.labrat_file.open("r") as f: 59 | data = json.load(f) 60 | self.assertEqual(len(data["projects"]), 1, "Project metadata was not updated.") 61 | self.assertEqual(data["projects"][0]["name"], "test_project", "Project name mismatch.") 62 | 63 | def test_delete_project(self): 64 | """ 65 | Test deletion of a project. 66 | """ 67 | self.manager.new_project( 68 | project_type="computational-biology", 69 | project_name="Test Project", 70 | project_path=self.project_path, 71 | description="A test project" 72 | ) 73 | self.manager.delete_project(self.project_path, self.archive_dir) 74 | self.assertFalse(self.project_path.exists(), "Project directory was not deleted.") 75 | self.assertTrue(any(self.archive_dir.iterdir()), "Project was not archived.") 76 | 77 | def test_add_template(self): 78 | """ 79 | Test adding a new template. 80 | """ 81 | self.manager.add_template("new-template", "https://github.com/example/cookiecutter-new") 82 | with self.labrat_file.open("r") as f: 83 | data = json.load(f) 84 | self.assertIn("new-template", data["templates"], "Template was not added.") 85 | 86 | if __name__ == "__main__": 87 | unittest.main() 88 | -------------------------------------------------------------------------------- /labrat/genetics/protein_analysis.py: -------------------------------------------------------------------------------- 1 | """Protein Analysis functions.""" 2 | 3 | from pathlib import Path 4 | from labrat.utils import import_json 5 | 6 | # Load codon dictionary from JSON file in the same directory 7 | _codons_json_path = Path(__file__).parent / "codons.json" 8 | __codons_dict = import_json(str(_codons_json_path)) 9 | CODONS = __codons_dict['CODONS'] 10 | 11 | 12 | def dna2aminoacid(fasta_file: str, codons: dict = None) -> str: 13 | """ 14 | Convert a DNA sequence from a FASTA file to an amino acid sequence. 15 | 16 | This function reads a FASTA-formatted file, extracts the DNA sequence 17 | (skipping the header line that starts with '>'), and translates it into 18 | an amino acid sequence using the genetic code. The sequence is processed 19 | in groups of three nucleotides (codons), and each codon is looked up in 20 | the codon dictionary to determine the corresponding amino acid. 21 | 22 | Args: 23 | fasta_file (str): Path to the FASTA file containing the DNA sequence. 24 | codons (dict, optional): Dictionary mapping 3-letter DNA codons to 25 | single-letter amino acid codes. Defaults to the standard genetic code. 26 | 27 | Returns: 28 | str: The translated amino acid sequence as a single-letter code string. 29 | 30 | Raises: 31 | FileNotFoundError: If the FASTA file cannot be found. 32 | KeyError: If an invalid codon (not in the dictionary) is encountered. 33 | ValueError: If the DNA sequence length is not a multiple of 3. 34 | 35 | Example: 36 | >>> sequence = dna2aminoacid("sequence.fasta") 37 | >>> print(sequence) 38 | MKTAYIAKQR... 39 | """ 40 | if codons is None: 41 | codons = CODONS 42 | 43 | # Read and parse the FASTA file 44 | # FASTA format: first line is a header (starts with '>'), followed by sequence lines 45 | dna_sequence = '' 46 | 47 | try: 48 | with open(fasta_file, 'r') as file_obj: 49 | for line in file_obj: 50 | # Skip header lines (start with '>') 51 | if line.startswith('>'): 52 | continue 53 | 54 | # Remove all whitespace and newlines, convert to uppercase 55 | # FASTA sequences may be split across multiple lines 56 | # Remove all whitespace characters (spaces, tabs, etc.) 57 | cleaned_line = ''.join(line.split()).upper() 58 | dna_sequence += cleaned_line 59 | except FileNotFoundError: 60 | raise FileNotFoundError(f"FASTA file not found: {fasta_file}") 61 | 62 | # Validate that we have a sequence 63 | if not dna_sequence: 64 | raise ValueError(f"No DNA sequence found in {fasta_file}") 65 | 66 | # Validate sequence length is a multiple of 3 (codons are triplets) 67 | if len(dna_sequence) % 3 != 0: 68 | raise ValueError( 69 | f"DNA sequence length ({len(dna_sequence)}) is not a multiple of 3. " 70 | "Codons must be complete triplets." 71 | ) 72 | 73 | # Translate DNA sequence to amino acid sequence 74 | # Process the sequence in groups of 3 nucleotides (codons) 75 | aa_sequence = '' 76 | 77 | for i in range(0, len(dna_sequence), 3): 78 | # Extract the current codon (3 nucleotides) 79 | codon = dna_sequence[i:i + 3] 80 | 81 | # Look up the codon in the dictionary to get the amino acid 82 | # Stop codons are represented as '_' in the codon table 83 | try: 84 | amino_acid = codons[codon] 85 | aa_sequence += amino_acid 86 | except KeyError: 87 | raise KeyError( 88 | f"Invalid codon '{codon}' at position {i}-{i+2}. " 89 | f"Valid codons contain only A, T, G, C." 90 | ) 91 | 92 | return aa_sequence 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | labrat-env/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # MacOSx 159 | .DS_Store 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ -------------------------------------------------------------------------------- /labrat/filemanager/archive.py: -------------------------------------------------------------------------------- 1 | from shutil import copytree, SameFileError, make_archive 2 | from datetime import datetime 3 | from pathlib import Path 4 | import logzero 5 | from logzero import logger 6 | from labrat.utils import get_labrat_dir 7 | 8 | 9 | class Archiver: 10 | """Archive folders and files.""" 11 | 12 | def __init__(self, source_dir, archive_dir): 13 | """ 14 | Initialize logger and archive parameters. 15 | 16 | Args: 17 | source_dir (str or Path): The directory to copy or archive. 18 | archive_dir (str or Path): The destination directory for the archive. 19 | """ 20 | self.source_dir = Path(source_dir).resolve() 21 | self.archive_dir = Path(archive_dir).resolve() 22 | 23 | if not self.source_dir.exists() or not self.source_dir.is_dir(): 24 | raise ValueError(f"Source directory '{self.source_dir}' does not exist or is not a directory.") 25 | logger.debug(f"Source directory: {self.source_dir}") 26 | 27 | if not self.archive_dir.parent.exists(): 28 | raise ValueError(f"Archive directory parent '{self.archive_dir.parent}' does not exist.") 29 | logger.debug(f"Archive directory: {self.archive_dir}") 30 | 31 | # Configure log file in .labrat directory 32 | # Store logs in user's home directory under .labrat folder 33 | labrat_dir = get_labrat_dir() 34 | log_file = labrat_dir / f"archive_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" 35 | logzero.logfile(str(log_file)) 36 | logger.info("Archive initialized.") 37 | 38 | def archive(self): 39 | """ 40 | Perform the archive by copying the source directory to the archive directory 41 | and then creating a zip file of the archived folder. 42 | 43 | Raises: 44 | SameFileError: If the source and destination are the same. 45 | OSError: For other filesystem-related errors. 46 | """ 47 | logger.info("Starting archive process...") 48 | logger.info(f"Source: {self.source_dir}") 49 | logger.info(f"Destination: {self.archive_dir}") 50 | 51 | # Step 1: Copy the source directory to the archive directory 52 | try: 53 | copytree(self.source_dir, self.archive_dir) 54 | logger.info(f"Archive folder created successfully: {self.source_dir} -> {self.archive_dir}") 55 | except SameFileError as e: 56 | logger.error(f"Source and destination are the same: {e}") 57 | raise 58 | except FileExistsError: 59 | logger.warning(f"Archive destination already exists: {self.archive_dir}") 60 | except OSError as e: 61 | logger.error(f"Failed to complete archive: {e}") 62 | raise 63 | 64 | # Create a zip file for the archived folder 65 | try: 66 | zip_path = make_archive( 67 | base_name=str(self.archive_dir), # The base name of the archive 68 | format="zip", # Archive format 69 | root_dir=str(self.archive_dir), # Root directory to archive 70 | ) 71 | logger.info(f"Archive zipped successfully: {zip_path}") 72 | except OSError as e: 73 | logger.error(f"Failed to zip the archive: {e}") 74 | raise 75 | 76 | return zip_path 77 | 78 | @staticmethod 79 | def get_archive_dir(base_dir, project_name): 80 | """ 81 | Generate a timestamped archive directory path. 82 | 83 | Args: 84 | base_dir (str or Path): The base directory where archives are stored. 85 | project_name (str): The name of the project being archived. 86 | 87 | Returns: 88 | Path: A path to the timestamped archive directory. 89 | """ 90 | base_dir = Path(base_dir).resolve() 91 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 92 | archive_dir = base_dir / f"{project_name}_archive_{timestamp}" 93 | logger.debug(f"Generated archive directory: {archive_dir}") 94 | return archive_dir 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # labrat 2 | 3 | [![Build Status](https://app.travis-ci.com/sdhutchins/labrat.svg?token=xfnbNTQhjNbir5xACn8R&branch=master)](https://app.travis-ci.com/sdhutchins/labrat) 4 | [![codecov](https://codecov.io/gh/sdhutchins/labrat/graph/badge.svg?token=LqA1Lqf0uu)](https://codecov.io/gh/sdhutchins/labrat) 5 | ![PyPI - Version](https://img.shields.io/pypi/v/pylabrat) 6 | [![DOI](https://zenodo.org/badge/99277244.svg)](https://doi.org/10.5281/zenodo.17705600) 7 | 8 | A basic science lab framework aimed at reproducibility and lab management. This package is in the very early stages of development. 9 | 10 | ## Features 11 | 12 | - Create, list, and track/manage computational biology projects with structured templates 13 | - Calculate solution dilutions, molarity, transmittance/absorbance conversions, and more 14 | - Automatically organize scientific data files (FASTQ, FASTA, SAM, BAM, VCF, etc.) and others files like pictures, videos, and archives 15 | - Archive projects and directories with timestamped backups 16 | - Convert DNA sequences to amino acids and analyze genetic data 17 | - Full-featured CLI for all major operations 18 | 19 | ## Install 20 | 21 | Install from PyPI: 22 | ```bash 23 | pip install pylabrat 24 | ``` 25 | 26 | Or install from source: 27 | ```bash 28 | git clone https://github.com/sdhutchins/labrat.git 29 | cd labrat 30 | pip install . 31 | ``` 32 | 33 | For development, install in editable mode: 34 | ```bash 35 | pip install -e . 36 | ``` 37 | 38 | ## Examples 39 | 40 | ### Command-Line Interface 41 | 42 | Create a new project: 43 | ```bash 44 | labrat project new --type computational-biology --name "KARG Analysis" \ 45 | --path ./karg_analysis --description "Analyze the KARG data" 46 | ``` 47 | 48 | List all projects: 49 | ```bash 50 | labrat project list 51 | ``` 52 | 53 | Archive files or directories: 54 | ```bash 55 | labrat archive --source ./my_project --destination ~/Archive --name "project_backup" 56 | ``` 57 | 58 | Organize scientific data files: 59 | ```bash 60 | labrat organize --science 61 | ``` 62 | 63 | ### Python API 64 | 65 | Calculate solution dilutions: 66 | ```python 67 | from labrat.math import dilute_stock 68 | 69 | # Calculate final concentration 70 | final_conc = dilute_stock(100, 2, vF=4) # Returns 50.0 71 | ``` 72 | 73 | Manage projects programmatically: 74 | ```python 75 | from labrat.project import ProjectManager 76 | 77 | # Create a new project 78 | manager = ProjectManager('Dr. Jane Doe') 79 | manager.new_project( 80 | project_type='computational-biology', 81 | project_name='KARG Analysis', 82 | project_path='./karg_analysis', 83 | description="Analyze the KARG data." 84 | ) 85 | 86 | # List all projects 87 | projects = manager.list_projects() 88 | ``` 89 | 90 | ## Tests 91 | 92 | Before running tests, ensure all dependencies are installed: 93 | 94 | ```bash 95 | pip install -r requirements.txt 96 | ``` 97 | 98 | Or if installing the package: 99 | 100 | ```bash 101 | pip install . 102 | ``` 103 | 104 | Run all tests using unittest: 105 | 106 | ```bash 107 | python -m unittest discover -s tests 108 | ``` 109 | 110 | Or run tests with pytest (if installed): 111 | 112 | ```bash 113 | pytest tests/ 114 | ``` 115 | 116 | To run a specific test file: 117 | 118 | ```bash 119 | python -m unittest tests.test_archiver 120 | python -m unittest tests.test_file_organizer 121 | python -m unittest tests.test_project_manager 122 | ``` 123 | 124 | ## ToDo 125 | 126 | - [ ] Add a lab inventory app 127 | - [ ] Add project report template 128 | - [ ] Integrate [exmemo](https://github.com/kalekundert/exmemo) 129 | 130 | ## Author 131 | 132 | Shaurita Hutchins · [@sdhutchins](https://github.com/sdhutchins) 133 | · [:email:](mailto:shaurita.d.hutchins@gmail.com) 134 | 135 | ## Contributing 136 | 137 | If you would like to contribute to this package, install the package in 138 | development mode, and check out our [contributing 139 | guidelines](https://github.com/sdhutchins/labrat/blob/master/CONTRIBUTING.md). 140 | 141 | ## License 142 | 143 | [MIT](https://github.com/sdhutchins/labrat/blob/master/LICENSE) 144 | -------------------------------------------------------------------------------- /labrat/math/functions.py: -------------------------------------------------------------------------------- 1 | """Reusable math functions for scientists.""" 2 | import math 3 | import numbers 4 | 5 | 6 | def dilute_stock(cI, vI, **values): 7 | """Dilute a stock concentration. 8 | 9 | Args: 10 | cI ([type]): [description] 11 | vI ([type]): [description] 12 | 13 | Raises: 14 | UserWarning: [description] 15 | KeyError: [description] 16 | 17 | Returns: 18 | [type]: [description] 19 | """ 20 | if not values: 21 | raise UserWarning('You did not enter any values.') 22 | for key, value in values.items(): 23 | if key in ('vF', 'vf'): 24 | final_concentration = (cI * vI) / value 25 | return final_concentration 26 | elif key in ('cF', 'cf'): 27 | final_volume = (cI * vI) / value 28 | return final_volume 29 | else: 30 | raise KeyError('%s is not a valid key.' % key) 31 | 32 | 33 | def mass_recorder(grams, item, sigfigs): 34 | """Record the mass of different items. 35 | 36 | Args: 37 | grams ([type]): [description] 38 | item ([type]): [description] 39 | sigfigs ([type]): [description] 40 | 41 | Raises: 42 | ValueError: [description] 43 | """ 44 | item_dict = {} 45 | if isinstance(grams, float) and isinstance(item, str) and isinstance(sigfigs, int): 46 | if item in item_dict: 47 | formatter = "{0:." + str(Sigfigs) + "f}" 48 | grams = formatter.format(grams) 49 | item_Dict[item] = grams 50 | else: 51 | formatter = "{0:." + str(sigfigs) + "f}" 52 | grams = formatter.format(grams) 53 | item_dict[item] += grams 54 | else: 55 | raise ValueError('Please input grams as a float value, item as a string, and sigfigs as an integer value') 56 | 57 | 58 | def transmittance_to_absorbance(transmittance): 59 | """Convert transmittance to absorbance. 60 | 61 | Args: 62 | transmittance ([type]): [description] 63 | 64 | Raises: 65 | ValueError: [description] 66 | 67 | Returns: 68 | [type]: [description] 69 | """ 70 | if isinstance(transmittance, numbers.Number): 71 | if transmittance == 0: 72 | return float('inf') 73 | t = transmittance / 100 74 | return math.log(t ** -1) 75 | else: 76 | raise ValueError("{} must be a number (percentage).") 77 | 78 | 79 | def calculate_molarity(moles, volume, units): 80 | """Calculate the molarity of a solution given moles and liters or mL. 81 | 82 | Args: 83 | moles ([type]): [description] 84 | volume ([type]): [description] 85 | units ([type]): [description] 86 | 87 | Raises: 88 | ValueError: [description] 89 | 90 | Returns: 91 | [type]: [description] 92 | """ 93 | if (units == 'ml' or units == 'mL'): 94 | volume = volume * 1000 95 | elif (units != 'l' and units != 'L'): 96 | raise ValueError('This unit of measurement is not supported.') 97 | return moles / volume 98 | 99 | 100 | def refractive_index_prism(prism, deviation, angle_measurement): 101 | """Calculate the refractive index of prism. 102 | 103 | This function uses the angle of prism and minimum angle of deviation. 104 | 105 | Args: 106 | prism ([type]): [description] 107 | deviation ([type]): [description] 108 | angle_measurement ([type]): [description] 109 | 110 | Raises: 111 | ValueError: [description] 112 | 113 | Returns: 114 | [type]: [description] 115 | """ 116 | if (angle_measurement == 'rad'): 117 | refractive_index = (math.sin((prism + deviation) / 2)) / math.sin(prism / 2) 118 | return refractive_index 119 | 120 | elif (angle_measurement == 'deg'): 121 | p = math.radians(prism) 122 | d = math.radians(deviation) 123 | refractive_index = (math.sin((p + d) / 2)) / math.sin(p / 2) 124 | return refractive_index 125 | 126 | else: 127 | raise ValueError('The angle measurement has to be in deg or rad format.') 128 | -------------------------------------------------------------------------------- /tests/test_protein_analysis.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | from labrat.genetics.protein_analysis import dna2aminoacid 4 | import shutil 5 | 6 | 7 | class TestProteinAnalysis(unittest.TestCase): 8 | """Test cases for the protein_analysis module.""" 9 | 10 | def setUp(self): 11 | """ 12 | Set up temporary directories and files for testing. 13 | """ 14 | self.test_dir = Path("./test_protein_analysis") 15 | self.test_dir.mkdir(parents=True, exist_ok=True) 16 | 17 | def tearDown(self): 18 | """ 19 | Clean up temporary files and directories. 20 | """ 21 | if self.test_dir.exists(): 22 | shutil.rmtree(self.test_dir) 23 | 24 | def test_dna2aminoacid_simple_sequence(self): 25 | """ 26 | Test translation of a simple DNA sequence. 27 | """ 28 | # Create a test FASTA file 29 | # ATG = M (start/methionine), AAA = K (lysine), TGG = W (tryptophan) 30 | fasta_file = self.test_dir / "test_simple.fasta" 31 | fasta_content = ">test_sequence\nATGAAATGG\n" 32 | fasta_file.write_text(fasta_content) 33 | 34 | result = dna2aminoacid(str(fasta_file)) 35 | self.assertEqual(result, "MKW") 36 | 37 | def test_dna2aminoacid_multiline_sequence(self): 38 | """ 39 | Test translation of a FASTA sequence split across multiple lines. 40 | """ 41 | fasta_file = self.test_dir / "test_multiline.fasta" 42 | fasta_content = ">test_sequence\nATGCGT\nAAGCTA\nTAA\n" 43 | fasta_file.write_text(fasta_content) 44 | 45 | result = dna2aminoacid(str(fasta_file)) 46 | # ATG=M, CGT=R, AAG=K, CTA=L, TAA=_ (stop codon) 47 | self.assertEqual(result, "MRKL_") 48 | 49 | def test_dna2aminoacid_with_stop_codon(self): 50 | """ 51 | Test translation including stop codons. 52 | """ 53 | fasta_file = self.test_dir / "test_stop.fasta" 54 | # ATG=M, TAA=_ (stop), TAG=_ (stop), TGA=_ (stop) 55 | fasta_content = ">test_sequence\nATGTAA\nTAGTGA\n" 56 | fasta_file.write_text(fasta_content) 57 | 58 | result = dna2aminoacid(str(fasta_file)) 59 | self.assertEqual(result, "M___") 60 | 61 | def test_dna2aminoacid_lowercase_sequence(self): 62 | """ 63 | Test that lowercase sequences are converted to uppercase. 64 | """ 65 | fasta_file = self.test_dir / "test_lowercase.fasta" 66 | fasta_content = ">test_sequence\natgaaatgg\n" 67 | fasta_file.write_text(fasta_content) 68 | 69 | result = dna2aminoacid(str(fasta_file)) 70 | self.assertEqual(result, "MKW") 71 | 72 | def test_dna2aminoacid_mixed_case_sequence(self): 73 | """ 74 | Test that mixed case sequences are handled correctly. 75 | """ 76 | fasta_file = self.test_dir / "test_mixed.fasta" 77 | fasta_content = ">test_sequence\nAtGaAaTgG\n" 78 | fasta_file.write_text(fasta_content) 79 | 80 | result = dna2aminoacid(str(fasta_file)) 81 | self.assertEqual(result, "MKW") 82 | 83 | def test_dna2aminoacid_with_whitespace(self): 84 | """ 85 | Test that whitespace in sequences is properly removed. 86 | """ 87 | fasta_file = self.test_dir / "test_whitespace.fasta" 88 | fasta_content = ">test_sequence\nATG AAA TGG\n" 89 | fasta_file.write_text(fasta_content) 90 | 91 | result = dna2aminoacid(str(fasta_file)) 92 | self.assertEqual(result, "MKW") 93 | 94 | def test_dna2aminoacid_custom_codons(self): 95 | """ 96 | Test translation with a custom codon dictionary. 97 | """ 98 | fasta_file = self.test_dir / "test_custom.fasta" 99 | fasta_content = ">test_sequence\nATGAAATGG\n" 100 | fasta_file.write_text(fasta_content) 101 | 102 | # Custom codon dictionary where ATG maps to X instead of M 103 | custom_codons = { 104 | "ATG": "X", 105 | "AAA": "K", 106 | "TGG": "W" 107 | } 108 | 109 | result = dna2aminoacid(str(fasta_file), codons=custom_codons) 110 | self.assertEqual(result, "XKW") 111 | 112 | def test_dna2aminoacid_file_not_found(self): 113 | """ 114 | Test that FileNotFoundError is raised for non-existent files. 115 | """ 116 | with self.assertRaises(FileNotFoundError): 117 | dna2aminoacid("nonexistent_file.fasta") 118 | 119 | def test_dna2aminoacid_empty_file(self): 120 | """ 121 | Test that ValueError is raised for empty FASTA files. 122 | """ 123 | fasta_file = self.test_dir / "test_empty.fasta" 124 | fasta_file.write_text(">header_only\n") 125 | 126 | with self.assertRaises(ValueError) as context: 127 | dna2aminoacid(str(fasta_file)) 128 | 129 | self.assertIn("No DNA sequence found", str(context.exception)) 130 | 131 | def test_dna2aminoacid_invalid_length(self): 132 | """ 133 | Test that ValueError is raised when sequence length is not a multiple of 3. 134 | """ 135 | fasta_file = self.test_dir / "test_invalid_length.fasta" 136 | # Sequence length is 10, not a multiple of 3 137 | fasta_content = ">test_sequence\nATGCGTAAAG\n" 138 | fasta_file.write_text(fasta_content) 139 | 140 | with self.assertRaises(ValueError) as context: 141 | dna2aminoacid(str(fasta_file)) 142 | 143 | self.assertIn("not a multiple of 3", str(context.exception)) 144 | 145 | def test_dna2aminoacid_invalid_codon(self): 146 | """ 147 | Test that KeyError is raised for invalid codons (containing non-ATGC characters). 148 | """ 149 | fasta_file = self.test_dir / "test_invalid_codon.fasta" 150 | # Contains 'N' which is not a valid nucleotide 151 | fasta_content = ">test_sequence\nATGNGG\n" 152 | fasta_file.write_text(fasta_content) 153 | 154 | with self.assertRaises(KeyError) as context: 155 | dna2aminoacid(str(fasta_file)) 156 | 157 | self.assertIn("Invalid codon", str(context.exception)) 158 | 159 | def test_dna2aminoacid_complex_sequence(self): 160 | """ 161 | Test translation of a longer, more complex sequence. 162 | """ 163 | fasta_file = self.test_dir / "test_complex.fasta" 164 | # A longer sequence with various codons (24 nucleotides = 8 codons) 165 | # ATG=M, CGA=R, GCT=A, GAC=D, GAT=D, GAG=E, GAG=E, GCC=A 166 | fasta_content = ">test_sequence\nATGCGAGCTGACGATGAGGAGGCC\n" 167 | fasta_file.write_text(fasta_content) 168 | 169 | result = dna2aminoacid(str(fasta_file)) 170 | # ATG=M, CGA=R, GCT=A, GAC=D, GAT=D, GAG=E, GAG=E, GCC=A 171 | expected = "MRADDEEA" 172 | self.assertEqual(result, expected) 173 | 174 | def test_dna2aminoacid_multiple_headers(self): 175 | """ 176 | Test that multiple header lines are properly skipped. 177 | """ 178 | fasta_file = self.test_dir / "test_multiple_headers.fasta" 179 | fasta_content = ">header1\n>header2\nATGAAATGG\n" 180 | fasta_file.write_text(fasta_content) 181 | 182 | result = dna2aminoacid(str(fasta_file)) 183 | self.assertEqual(result, "MKW") 184 | 185 | 186 | if __name__ == "__main__": 187 | unittest.main() 188 | -------------------------------------------------------------------------------- /tests/test_math_functions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import math 3 | from labrat.math import ( 4 | dilute_stock, 5 | transmittance_to_absorbance, 6 | calculate_molarity, 7 | refractive_index_prism, 8 | ) 9 | 10 | 11 | class TestDiluteStock(unittest.TestCase): 12 | """Test cases for the dilute_stock function.""" 13 | 14 | def test_calculate_final_concentration_with_vF(self): 15 | """Test calculating final concentration when final volume is provided.""" 16 | # cI = 100, vI = 2, vF = 4 17 | # Expected: (100 * 2) / 4 = 50.0 18 | result = dilute_stock(100, 2, vF=4) 19 | self.assertAlmostEqual(result, 50.0, places=5) 20 | 21 | def test_calculate_final_volume_with_cF(self): 22 | """Test calculating final volume when final concentration is provided.""" 23 | # cI = 100, vI = 2, cF = 50 24 | # Expected: (100 * 2) / 50 = 4.0 25 | result = dilute_stock(100, 2, cF=50) 26 | self.assertAlmostEqual(result, 4.0, places=5) 27 | 28 | def test_dilute_stock_with_float_values(self): 29 | """Test dilute_stock with floating point values.""" 30 | result = dilute_stock(50.5, 1.5, vF=3.0) 31 | expected = (50.5 * 1.5) / 3.0 32 | self.assertAlmostEqual(result, expected, places=5) 33 | 34 | def test_dilute_stock_no_values(self): 35 | """Test that UserWarning is raised when no values are provided.""" 36 | with self.assertRaises(UserWarning): 37 | dilute_stock(100, 2) 38 | 39 | def test_dilute_stock_invalid_key(self): 40 | """Test that KeyError is raised for invalid keys.""" 41 | with self.assertRaises(KeyError): 42 | dilute_stock(100, 2, invalid_key=5) 43 | 44 | 45 | class TestTransmittanceToAbsorbance(unittest.TestCase): 46 | """Test cases for the transmittance_to_absorbance function.""" 47 | 48 | def test_transmittance_50_percent(self): 49 | """Test conversion of 50% transmittance to absorbance.""" 50 | result = transmittance_to_absorbance(50) 51 | # A = -log10(T) = -log10(0.5) ≈ 0.3010 52 | expected = math.log(2) # log(1/0.5) = log(2) 53 | self.assertAlmostEqual(result, expected, places=5) 54 | 55 | def test_transmittance_100_percent(self): 56 | """Test conversion of 100% transmittance to absorbance.""" 57 | result = transmittance_to_absorbance(100) 58 | # A = -log10(1) = 0 59 | expected = math.log(1) # log(1/1) = log(1) = 0 60 | self.assertAlmostEqual(result, expected, places=5) 61 | 62 | def test_transmittance_10_percent(self): 63 | """Test conversion of 10% transmittance to absorbance.""" 64 | result = transmittance_to_absorbance(10) 65 | # A = -log10(0.1) = 1 66 | expected = math.log(10) # log(1/0.1) = log(10) 67 | self.assertAlmostEqual(result, expected, places=5) 68 | 69 | def test_transmittance_float_value(self): 70 | """Test conversion with floating point transmittance.""" 71 | result = transmittance_to_absorbance(25.5) 72 | expected = math.log(100 / 25.5) 73 | self.assertAlmostEqual(result, expected, places=5) 74 | 75 | def test_transmittance_invalid_input(self): 76 | """Test that ValueError is raised for non-numeric input.""" 77 | with self.assertRaises(ValueError): 78 | transmittance_to_absorbance("50") 79 | 80 | def test_transmittance_zero_percent(self): 81 | """Test edge case of 0% transmittance.""" 82 | result = transmittance_to_absorbance(0) 83 | # 0% transmittance results in infinite absorbance 84 | self.assertTrue(math.isinf(result)) 85 | self.assertGreater(result, 0) 86 | 87 | 88 | class TestCalculateMolarity(unittest.TestCase): 89 | """Test cases for the calculate_molarity function.""" 90 | 91 | def test_molarity_with_liters(self): 92 | """Test molarity calculation with liters.""" 93 | # 2 moles in 1 liter = 2 M 94 | result = calculate_molarity(2, 1, 'L') 95 | self.assertAlmostEqual(result, 2.0, places=5) 96 | 97 | def test_molarity_with_lowercase_liters(self): 98 | """Test molarity calculation with lowercase 'l'.""" 99 | result = calculate_molarity(1, 2, 'l') 100 | self.assertAlmostEqual(result, 0.5, places=5) 101 | 102 | def test_molarity_with_milliliters(self): 103 | """Test molarity calculation with milliliters.""" 104 | # 1 mole in 500 mL = 1 / (500 * 1000) = 1 / 500000 105 | # Note: The function multiplies by 1000, which appears to be a bug 106 | result = calculate_molarity(1, 500, 'mL') 107 | expected = 1 / (500 * 1000) 108 | self.assertAlmostEqual(result, expected, places=5) 109 | 110 | def test_molarity_with_lowercase_ml(self): 111 | """Test molarity calculation with lowercase 'ml'.""" 112 | result = calculate_molarity(0.5, 250, 'ml') 113 | expected = 0.5 / (250 * 1000) 114 | self.assertAlmostEqual(result, expected, places=5) 115 | 116 | def test_molarity_with_float_values(self): 117 | """Test molarity calculation with floating point values.""" 118 | result = calculate_molarity(0.25, 1.5, 'L') 119 | expected = 0.25 / 1.5 120 | self.assertAlmostEqual(result, expected, places=5) 121 | 122 | def test_molarity_invalid_units(self): 123 | """Test that ValueError is raised for unsupported units.""" 124 | with self.assertRaises(ValueError): 125 | calculate_molarity(1, 1, 'gallons') 126 | 127 | 128 | class TestRefractiveIndexPrism(unittest.TestCase): 129 | """Test cases for the refractive_index_prism function.""" 130 | 131 | def test_refractive_index_degrees(self): 132 | """Test refractive index calculation with degrees.""" 133 | # Using known values: prism angle = 60°, deviation = 40° 134 | # n = sin((60 + 40)/2) / sin(60/2) = sin(50°) / sin(30°) 135 | prism = 60.0 136 | deviation = 40.0 137 | result = refractive_index_prism(prism, deviation, 'deg') 138 | 139 | p_rad = math.radians(prism) 140 | d_rad = math.radians(deviation) 141 | expected = math.sin((p_rad + d_rad) / 2) / math.sin(p_rad / 2) 142 | 143 | self.assertAlmostEqual(result, expected, places=5) 144 | 145 | def test_refractive_index_radians(self): 146 | """Test refractive index calculation with radians.""" 147 | # Using known values in radians 148 | prism = math.radians(60.0) 149 | deviation = math.radians(40.0) 150 | result = refractive_index_prism(prism, deviation, 'rad') 151 | 152 | expected = math.sin((prism + deviation) / 2) / math.sin(prism / 2) 153 | 154 | self.assertAlmostEqual(result, expected, places=5) 155 | 156 | def test_refractive_index_degrees_float(self): 157 | """Test refractive index with floating point degrees.""" 158 | prism = 45.5 159 | deviation = 30.25 160 | result = refractive_index_prism(prism, deviation, 'deg') 161 | 162 | p_rad = math.radians(prism) 163 | d_rad = math.radians(deviation) 164 | expected = math.sin((p_rad + d_rad) / 2) / math.sin(p_rad / 2) 165 | 166 | self.assertAlmostEqual(result, expected, places=5) 167 | 168 | def test_refractive_index_invalid_angle_measurement(self): 169 | """Test that ValueError is raised for invalid angle measurement.""" 170 | with self.assertRaises(ValueError): 171 | refractive_index_prism(60, 40, 'invalid') 172 | 173 | def test_refractive_index_consistency_deg_vs_rad(self): 174 | """Test that degrees and radians give same result for same angles.""" 175 | prism_deg = 60.0 176 | deviation_deg = 40.0 177 | 178 | result_deg = refractive_index_prism(prism_deg, deviation_deg, 'deg') 179 | result_rad = refractive_index_prism( 180 | math.radians(prism_deg), 181 | math.radians(deviation_deg), 182 | 'rad' 183 | ) 184 | 185 | self.assertAlmostEqual(result_deg, result_rad, places=5) 186 | 187 | 188 | if __name__ == "__main__": 189 | unittest.main() 190 | -------------------------------------------------------------------------------- /labrat/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Command-line interface for labrat.""" 3 | import click 4 | from pathlib import Path 5 | from labrat.project import ProjectManager 6 | from labrat.filemanager import Archiver, FileOrganizer 7 | 8 | 9 | @click.group() 10 | def main(): 11 | """Labrat - A basic science lab framework for reproducibility and lab management.""" 12 | pass 13 | 14 | 15 | @main.group() 16 | def project(): 17 | """Manage projects.""" 18 | pass 19 | 20 | 21 | @project.command('new') 22 | @click.option('--type', 'project_type', required=True, 23 | help='Type of project (e.g., computational-biology, data-science)') 24 | @click.option('--name', 'project_name', required=True, 25 | help='Name of the project') 26 | @click.option('--path', 'project_path', required=True, type=click.Path(), 27 | help='Path where the project will be created') 28 | @click.option('--description', required=True, 29 | help='Description of the project') 30 | @click.option('--username', default=None, 31 | help='Username for project manager (defaults to system default)') 32 | def new_project(project_type, project_name, project_path, description, username): 33 | """Create a new project.""" 34 | try: 35 | manager = ProjectManager(username=username) 36 | manager.new_project( 37 | project_type=project_type, 38 | project_name=project_name, 39 | project_path=project_path, 40 | description=description 41 | ) 42 | click.echo(f"✓ Project '{project_name}' created successfully at {project_path}") 43 | except Exception as e: 44 | click.echo(f"✗ Error creating project: {e}", err=True) 45 | raise click.Abort() 46 | 47 | 48 | @project.command('list') 49 | @click.option('--username', default=None, 50 | help='Username for project manager (defaults to system default)') 51 | def list_projects(username): 52 | """List all projects.""" 53 | try: 54 | manager = ProjectManager(username=username) 55 | projects = manager.list_projects() 56 | 57 | if not projects: 58 | click.echo("No projects found.") 59 | return 60 | 61 | click.echo(f"\nFound {len(projects)} project(s):\n") 62 | for idx, proj in enumerate(projects, 1): 63 | click.echo(f"{idx}. {proj.get('name', 'Unknown')}") 64 | click.echo(f" Path: {proj.get('path', 'Unknown')}") 65 | click.echo(f" Type: {proj.get('project_type', 'Unknown')}") 66 | click.echo(f" Created: {proj.get('created_at', 'Unknown')}") 67 | click.echo() 68 | except Exception as e: 69 | click.echo(f"✗ Error listing projects: {e}", err=True) 70 | raise click.Abort() 71 | 72 | 73 | @project.command('delete') 74 | @click.option('--path', 'project_path', required=True, type=click.Path(exists=True), 75 | help='Path to the project to delete') 76 | @click.option('--archive-dir', required=True, type=click.Path(), 77 | help='Directory where the archived project will be stored') 78 | @click.option('--username', default=None, 79 | help='Username for project manager (defaults to system default)') 80 | @click.confirmation_option(prompt='Are you sure you want to delete this project?') 81 | def delete_project(project_path, archive_dir, username): 82 | """Delete a project (archives it first).""" 83 | try: 84 | manager = ProjectManager(username=username) 85 | archive_path = manager.delete_project(project_path, archive_dir) 86 | click.echo(f"✓ Project deleted and archived to: {archive_path}") 87 | except Exception as e: 88 | click.echo(f"✗ Error deleting project: {e}", err=True) 89 | raise click.Abort() 90 | 91 | 92 | @main.command('archive') 93 | @click.option('--source', required=True, type=click.Path(exists=True, dir_okay=True), 94 | help='Source directory to archive') 95 | @click.option('--destination', required=True, type=click.Path(), 96 | help='Base directory for storing archives') 97 | @click.option('--name', 'project_name', required=True, 98 | help='Name for the archive') 99 | def archive(source, destination, project_name): 100 | """Archive a directory.""" 101 | try: 102 | archive_dir = Archiver.get_archive_dir(destination, project_name) 103 | archiver = Archiver(source_dir=source, archive_dir=archive_dir) 104 | zip_path = archiver.archive() 105 | click.echo(f"✓ Archive created successfully: {zip_path}") 106 | except Exception as e: 107 | click.echo(f"✗ Error creating archive: {e}", err=True) 108 | raise click.Abort() 109 | 110 | 111 | @main.command('organize') 112 | @click.option('--science', 'organize_science', is_flag=True, 113 | help='Organize scientific data files (fastq, fasta, sam, bam, vcf, fits, hdf5, etc.) to Documents/Research_Data') 114 | @click.option('--science-dir', type=click.Path(), 115 | help='Custom directory for scientific data files (default: Documents/Research_Data)') 116 | @click.option('--keyword', default=None, 117 | help='Move files containing this keyword to a specific folder') 118 | @click.option('--pictures', 'organize_pictures', is_flag=True, 119 | help='Organize picture files to Pictures folder') 120 | @click.option('--videos', 'organize_videos', is_flag=True, 121 | help='Organize video files to Videos folder') 122 | @click.option('--archives', 'organize_archives', is_flag=True, 123 | help='Organize archive files by compression type') 124 | @click.option('--all', 'organize_all', is_flag=True, 125 | help='Organize all file types') 126 | def organize(organize_science, science_dir, keyword, organize_pictures, 127 | organize_videos, organize_archives, organize_all): 128 | """ 129 | Organize files in Downloads and Documents directories. 130 | 131 | By default, scientific data files (fastq, fasta, sam, bam, vcf, fits, hdf5, nc, etc.) 132 | are moved to Documents/Research_Data. Use --science-dir to specify a custom location. 133 | 134 | Examples: 135 | labrat organize --science 136 | labrat organize --science --science-dir ~/Research 137 | labrat organize --keyword "project_alpha" 138 | labrat organize --all 139 | """ 140 | if not any([organize_science, keyword, organize_pictures, organize_videos, 141 | organize_archives, organize_all]): 142 | click.echo("Error: Specify at least one organization option", err=True) 143 | click.echo("Use --science to organize science files, or --all for everything", err=True) 144 | raise click.Abort() 145 | 146 | try: 147 | organizer = FileOrganizer() 148 | actions_taken = [] 149 | 150 | if organize_all: 151 | organizer.organize_all() 152 | actions_taken.append("all files") 153 | else: 154 | # Organize science files (default behavior for scientists) 155 | if organize_science: 156 | organizer.organize_science_files(science_dir=science_dir) 157 | location = science_dir if science_dir else "Documents/Research_Data" 158 | actions_taken.append(f"science files to {location}") 159 | 160 | # Organize media files 161 | if organize_pictures or organize_videos: 162 | organizer.organize_files() 163 | media = [] 164 | if organize_pictures: 165 | media.append("pictures") 166 | if organize_videos: 167 | media.append("videos") 168 | actions_taken.append(f"{' and '.join(media)}") 169 | 170 | # Organize archives 171 | if organize_archives: 172 | organizer.organize_archives() 173 | actions_taken.append("archives") 174 | 175 | # Handle keyword-based organization 176 | if keyword: 177 | organizer.move_specific_files(keyword=keyword) 178 | actions_taken.append(f"files with keyword '{keyword}'") 179 | 180 | click.echo(f"✓ Organized {', '.join(actions_taken)} successfully") 181 | except Exception as e: 182 | click.echo(f"✗ Error organizing files: {e}", err=True) 183 | raise click.Abort() 184 | 185 | 186 | if __name__ == "__main__": 187 | main() 188 | -------------------------------------------------------------------------------- /labrat/filemanager/organize.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | import datetime 4 | import logzero 5 | from logzero import logger 6 | from labrat.utils import get_labrat_dir 7 | 8 | 9 | class FileOrganizer: 10 | """ 11 | A utility class for organizing files in standard directories like Downloads and Documents. 12 | Designed for integration into the labrat tool. 13 | """ 14 | 15 | def __init__(self): 16 | """ 17 | Initialize the FileOrganizer class with default directories. 18 | """ 19 | self.downloads_dir = Path.home() / "Downloads" 20 | self.documents_dir = Path.home() / "Documents" 21 | self.pictures_dir = Path.home() / "Pictures" 22 | self.videos_dir = Path.home() / "Videos" 23 | self.archive_dir = self.documents_dir / "Archive" 24 | self.specific_dir = self.documents_dir / "Organized_Files" 25 | # Default science files directory for organizing scientific data files 26 | self.science_dir = self.documents_dir / "Research_Data" 27 | 28 | # Subfolders for documents and archives 29 | self.document_subfolders = { 30 | "Word": ["docx", "doc"], 31 | "Excel": ["xlsx", "xls"], 32 | "Presentations": ["pptx", "ppt"], 33 | "PDFs": ["pdf"], 34 | "Scripts": ["py", "r", "sh", "rmd"], 35 | "TextFiles": ["txt", "md", "csv", "tsv", "xml", "json", "yaml", "rtf"] 36 | } 37 | 38 | self.archive_subfolders = { 39 | "ZIP": ["zip"], 40 | "DMG": ["dmg"], 41 | "TAR": ["tar", "tgz", "gz", "bz2", "xz"], 42 | "RAR": ["rar"], 43 | "Installers": ["pkg", "exe", "msi"], 44 | "Certificates": ["crt"] 45 | } 46 | 47 | self.file_extensions = { 48 | "pictures": [ 49 | ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", 50 | ".heic", ".webp", ".svg", ".raw", ".nef", ".cr2", 51 | ".orf", ".arw", ".psd", ".ai" 52 | ], 53 | "videos": [ 54 | ".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", 55 | ".m4v", ".mpeg", ".3gp", ".webm" 56 | ] 57 | } 58 | 59 | # Scientific data file extensions for research data files 60 | # Bioinformatics & Genomics 61 | # Astronomy & Physics 62 | self.science_extensions = [ 63 | # Bioinformatics & Genomics 64 | "fastq", "fq", # Sequencing data 65 | "fasta", "fa", # Sequence files 66 | "sam", "bam", "cram", # Alignment files 67 | "vcf", "bcf", # Variant call format 68 | "bed", "gff", "gtf", # Genomic annotations 69 | "nex", "nexus", "phylip", # Phylogenetic data 70 | "pdb", "mmcif", # Protein structures 71 | # Astronomy & Physics 72 | "fits", "fit", # Astronomy images/data 73 | "hdf5", "h5", # Hierarchical data format 74 | "nc", "nc4", # NetCDF (climate/oceanography) 75 | "cdf", # Common Data Format (NASA) 76 | ] 77 | 78 | # Configure logzero 79 | # Store logs in user's home directory under .labrat folder 80 | labrat_dir = get_labrat_dir() 81 | log_file = labrat_dir / "file_organizer.log" 82 | logzero.logfile(str(log_file)) 83 | logger.info("FileOrganizer initialized.") 84 | 85 | def move_file(self, src_file: Path, dest_dir: Path): 86 | """ 87 | Move a file to the destination directory, handling duplicates and logging actions. 88 | 89 | Args: 90 | src_file (Path): The source file to move. 91 | dest_dir (Path): The destination directory where the file will be moved. 92 | """ 93 | dest_dir.mkdir(parents=True, exist_ok=True) 94 | dest_file = dest_dir / src_file.name 95 | 96 | if dest_file.exists(): 97 | if src_file.stat().st_mtime > dest_file.stat().st_mtime: 98 | dest_file.unlink() 99 | logger.info(f"Replaced older file: {dest_file}") 100 | else: 101 | logger.info(f"Skipped older file: {src_file}") 102 | src_file.unlink() 103 | return 104 | else: 105 | logger.info(f"Moving file: {src_file} -> {dest_dir}") 106 | 107 | shutil.move(str(src_file), str(dest_dir)) 108 | 109 | def organize_by_year(self, file_path: Path, base_dir: Path): 110 | """ 111 | Organize files into subfolders by year based on the last modified timestamp. 112 | 113 | Args: 114 | file_path (Path): The file to organize. 115 | base_dir (Path): The base directory where files are organized. 116 | """ 117 | year = datetime.datetime.fromtimestamp(file_path.stat().st_mtime).year 118 | year_dir = base_dir / str(year) 119 | self.move_file(file_path, year_dir) 120 | 121 | def organize_documents(self): 122 | """ 123 | Organize documents into subfolders by type and year. 124 | """ 125 | logger.info("Organizing documents...") 126 | for subfolder, exts in self.document_subfolders.items(): 127 | for ext in exts: 128 | for file_path in self.documents_dir.glob(f"*.{ext}"): 129 | self.organize_by_year(file_path, self.documents_dir / subfolder) 130 | for file_path in self.downloads_dir.glob(f"*.{ext}"): 131 | self.organize_by_year(file_path, self.documents_dir / subfolder) 132 | logger.info("Finished organizing documents.") 133 | 134 | def organize_archives(self): 135 | """ 136 | Organize archives into subfolders by compression type. 137 | """ 138 | logger.info("Organizing archives...") 139 | for subfolder, exts in self.archive_subfolders.items(): 140 | for ext in exts: 141 | for file_path in self.documents_dir.glob(f"*.{ext}"): 142 | self.move_file(file_path, self.archive_dir / subfolder) 143 | for file_path in self.downloads_dir.glob(f"*.{ext}"): 144 | self.move_file(file_path, self.archive_dir / subfolder) 145 | logger.info("Finished organizing archives.") 146 | 147 | def organize_files(self): 148 | """ 149 | Organize files into standard categories (e.g., Pictures, Videos). 150 | """ 151 | logger.info("Organizing files into categories...") 152 | categories = { 153 | "pictures": { 154 | "sources": [self.downloads_dir, self.documents_dir], 155 | "dest": self.pictures_dir 156 | }, 157 | "videos": { 158 | "sources": [self.downloads_dir, self.documents_dir], 159 | "dest": self.videos_dir 160 | } 161 | } 162 | 163 | for category, info in categories.items(): 164 | for source_dir in info["sources"]: 165 | for ext in self.file_extensions[category]: 166 | for file_path in source_dir.glob(f"*{ext}"): 167 | self.move_file(file_path, info["dest"]) 168 | logger.info("Finished organizing files.") 169 | 170 | def move_specific_files(self, keyword: str = "specific"): 171 | """ 172 | Move files containing a specific keyword in their names to a designated folder. 173 | 174 | Args: 175 | keyword (str): Keyword to match in filenames. Default is 'specific'. 176 | """ 177 | logger.info(f"Moving files containing keyword '{keyword}'...") 178 | for source_dir in [self.downloads_dir, self.documents_dir]: 179 | for file_path in source_dir.glob(f"*{keyword}*"): 180 | self.move_file(file_path, self.specific_dir) 181 | logger.info("Finished moving specific files.") 182 | 183 | def organize_science_files(self, science_dir=None): 184 | """ 185 | Organize scientific data files into a dedicated research files folder. 186 | 187 | Handles common scientific file formats including: 188 | - Bioinformatics: fastq, fasta, sam, bam, vcf, bed, gff, gtf 189 | - Astronomy: fits, hdf5, nc (NetCDF) 190 | - General: csv, tsv, json, h5, parquet 191 | - Computational: ipynb, py, r, R 192 | 193 | Args: 194 | science_dir (Path, optional): Custom directory for science files. 195 | Defaults to Documents/Research_Data. 196 | """ 197 | if science_dir is None: 198 | science_dir = self.science_dir 199 | else: 200 | science_dir = Path(science_dir) 201 | 202 | logger.info(f"Organizing scientific data files to {science_dir}...") 203 | 204 | # Search in Downloads and Documents 205 | for source_dir in [self.downloads_dir, self.documents_dir]: 206 | for ext in self.science_extensions: 207 | # Handle case-insensitive extensions 208 | # For extensions with dots (like .fq.gz), handle them specially 209 | if "." in ext: 210 | # Multi-part extension like .fq.gz 211 | pattern = f"*.{ext}" 212 | for file_path in source_dir.glob(pattern): 213 | self.move_file(file_path, science_dir) 214 | # Also try uppercase 215 | pattern_upper = f"*.{ext.upper()}" 216 | for file_path in source_dir.glob(pattern_upper): 217 | self.move_file(file_path, science_dir) 218 | else: 219 | # Simple extension 220 | for pattern in [f"*.{ext}", f"*.{ext.upper()}", f"*.{ext.capitalize()}"]: 221 | for file_path in source_dir.glob(pattern): 222 | self.move_file(file_path, science_dir) 223 | 224 | logger.info("Finished organizing scientific data files.") 225 | 226 | def organize_all(self): 227 | """ 228 | Execute all organizational tasks in sequence. 229 | """ 230 | logger.info("Starting file organization tasks...") 231 | self.organize_documents() 232 | self.organize_archives() 233 | self.organize_files() 234 | self.move_specific_files(keyword="shaurita") 235 | logger.info("File organization tasks completed.") 236 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | from unittest.mock import patch, MagicMock 4 | from click.testing import CliRunner 5 | from labrat.cli import main 6 | 7 | 8 | @pytest.fixture 9 | def runner(): 10 | """CLI runner fixture.""" 11 | return CliRunner() 12 | 13 | def test_main_help(runner): 14 | """Test that the main command shows help.""" 15 | result = runner.invoke(main, ['--help']) 16 | assert result.exit_code == 0 17 | assert "Labrat" in result.output 18 | assert "project" in result.output 19 | assert "archive" in result.output 20 | assert "organize" in result.output 21 | 22 | def test_project_group_help(runner): 23 | """Test that the project group shows help.""" 24 | result = runner.invoke(main, ['project', '--help']) 25 | assert result.exit_code == 0 26 | assert "Manage projects" in result.output 27 | assert "new" in result.output 28 | assert "list" in result.output 29 | assert "delete" in result.output 30 | 31 | @patch('labrat.cli.ProjectManager') 32 | def test_project_new_success(mock_project_manager_class, runner): 33 | """Test creating a new project via CLI.""" 34 | mock_manager = MagicMock() 35 | mock_project_manager_class.return_value = mock_manager 36 | 37 | result = runner.invoke(main, [ 38 | 'project', 'new', 39 | '--type', 'computational-biology', 40 | '--name', 'Test Project', 41 | '--path', '/tmp/test_project', 42 | '--description', 'A test project' 43 | ]) 44 | 45 | assert result.exit_code == 0 46 | assert "✓ Project 'Test Project' created successfully" in result.output 47 | mock_manager.new_project.assert_called_once_with( 48 | project_type='computational-biology', 49 | project_name='Test Project', 50 | project_path='/tmp/test_project', 51 | description='A test project' 52 | ) 53 | 54 | def test_project_new_missing_options(runner): 55 | """Test that missing required options show an error.""" 56 | result = runner.invoke(main, [ 57 | 'project', 'new', 58 | '--name', 'Test Project' 59 | ]) 60 | 61 | assert result.exit_code != 0 62 | assert "Error" in result.output 63 | 64 | @patch('labrat.cli.ProjectManager') 65 | def test_project_new_error_handling(mock_project_manager_class, runner): 66 | """Test error handling when project creation fails.""" 67 | mock_manager = MagicMock() 68 | mock_manager.new_project.side_effect = ValueError("Invalid project type") 69 | mock_project_manager_class.return_value = mock_manager 70 | 71 | result = runner.invoke(main, [ 72 | 'project', 'new', 73 | '--type', 'invalid-type', 74 | '--name', 'Test Project', 75 | '--path', '/tmp/test_project', 76 | '--description', 'A test project' 77 | ]) 78 | 79 | assert result.exit_code != 0 80 | assert "✗ Error creating project" in result.output 81 | 82 | @patch('labrat.cli.ProjectManager') 83 | def test_project_list_empty(mock_project_manager_class, runner): 84 | """Test listing projects when none exist.""" 85 | mock_manager = MagicMock() 86 | mock_manager.list_projects.return_value = [] 87 | mock_project_manager_class.return_value = mock_manager 88 | 89 | result = runner.invoke(main, ['project', 'list']) 90 | 91 | assert result.exit_code == 0 92 | assert "No projects found" in result.output 93 | 94 | @patch('labrat.cli.ProjectManager') 95 | def test_project_list_with_projects(mock_project_manager_class, runner): 96 | """Test listing projects when projects exist.""" 97 | mock_manager = MagicMock() 98 | mock_manager.list_projects.return_value = [ 99 | { 100 | 'name': 'project1', 101 | 'path': '/path/to/project1', 102 | 'project_type': 'computational-biology', 103 | 'created_at': '2024-01-01T00:00:00' 104 | }, 105 | { 106 | 'name': 'project2', 107 | 'path': '/path/to/project2', 108 | 'project_type': 'data-science', 109 | 'created_at': '2024-01-02T00:00:00' 110 | } 111 | ] 112 | mock_project_manager_class.return_value = mock_manager 113 | 114 | result = runner.invoke(main, ['project', 'list']) 115 | 116 | assert result.exit_code == 0 117 | assert "Found 2 project(s)" in result.output 118 | assert "project1" in result.output 119 | assert "project2" in result.output 120 | 121 | @patch('labrat.cli.ProjectManager') 122 | def test_project_delete_with_confirmation(mock_project_manager_class, runner, tmp_path): 123 | """Test deleting a project with confirmation.""" 124 | test_project_dir = tmp_path / "test_project" 125 | test_archive_dir = tmp_path / "archive" 126 | test_project_dir.mkdir() 127 | test_archive_dir.mkdir() 128 | 129 | mock_manager = MagicMock() 130 | mock_manager.delete_project.return_value = str(test_archive_dir) 131 | mock_project_manager_class.return_value = mock_manager 132 | 133 | result = runner.invoke(main, [ 134 | 'project', 'delete', 135 | '--path', str(test_project_dir), 136 | '--archive-dir', str(test_archive_dir) 137 | ], input='y\n') 138 | 139 | assert result.exit_code == 0 140 | assert "✓ Project deleted and archived" in result.output 141 | mock_manager.delete_project.assert_called_once_with( 142 | str(test_project_dir), 143 | str(test_archive_dir) 144 | ) 145 | 146 | @patch('labrat.cli.ProjectManager') 147 | def test_project_delete_without_confirmation(mock_project_manager_class, runner, tmp_path): 148 | """Test that deleting a project without confirmation is aborted.""" 149 | test_project_dir = tmp_path / "test_project" 150 | test_archive_dir = tmp_path / "archive" 151 | test_project_dir.mkdir() 152 | test_archive_dir.mkdir() 153 | 154 | mock_manager = MagicMock() 155 | mock_project_manager_class.return_value = mock_manager 156 | 157 | result = runner.invoke(main, [ 158 | 'project', 'delete', 159 | '--path', str(test_project_dir), 160 | '--archive-dir', str(test_archive_dir) 161 | ], input='n\n') 162 | 163 | assert result.exit_code != 0 164 | mock_manager.delete_project.assert_not_called() 165 | 166 | @patch('labrat.cli.Archiver') 167 | def test_archive_success(mock_archiver_class, runner, tmp_path): 168 | """Test archiving a directory via CLI.""" 169 | test_source_dir = tmp_path / "source" 170 | test_destination_dir = tmp_path / "archive" 171 | test_source_dir.mkdir() 172 | test_destination_dir.mkdir() 173 | 174 | mock_archive_dir = tmp_path / "test_archive_20240101_120000" 175 | # Mock the static method 176 | mock_archiver_class.get_archive_dir.return_value = mock_archive_dir 177 | 178 | # Mock the instance and its archive method 179 | mock_archiver_instance = MagicMock() 180 | mock_archiver_instance.archive.return_value = str(mock_archive_dir) + '.zip' 181 | # When Archiver() is called, return our mock instance 182 | mock_archiver_class.return_value = mock_archiver_instance 183 | 184 | result = runner.invoke(main, [ 185 | 'archive', 186 | '--source', str(test_source_dir), 187 | '--destination', str(test_destination_dir), 188 | '--name', 'test_archive' 189 | ]) 190 | 191 | assert result.exit_code == 0, f"Error: {result.output}" 192 | assert "✓ Archive created successfully" in result.output 193 | # Verify get_archive_dir was called correctly 194 | mock_archiver_class.get_archive_dir.assert_called_once() 195 | # Verify Archiver was instantiated 196 | assert mock_archiver_class.called 197 | # Verify archive method was called 198 | mock_archiver_instance.archive.assert_called_once() 199 | 200 | @patch('labrat.cli.FileOrganizer') 201 | def test_organize_science_files(mock_organizer_class, runner): 202 | """Test organizing science files.""" 203 | mock_organizer = MagicMock() 204 | mock_organizer_class.return_value = mock_organizer 205 | 206 | result = runner.invoke(main, ['organize', '--science']) 207 | 208 | assert result.exit_code == 0 209 | assert "✓ Organized" in result.output 210 | assert "science files" in result.output 211 | mock_organizer.organize_science_files.assert_called_once_with(science_dir=None) 212 | 213 | @patch('labrat.cli.FileOrganizer') 214 | def test_organize_science_files_custom_dir(mock_organizer_class, runner): 215 | """Test organizing science files to a custom directory.""" 216 | mock_organizer = MagicMock() 217 | mock_organizer_class.return_value = mock_organizer 218 | 219 | result = runner.invoke(main, [ 220 | 'organize', '--science', '--science-dir', '/custom/path' 221 | ]) 222 | 223 | assert result.exit_code == 0 224 | assert "✓ Organized" in result.output 225 | mock_organizer.organize_science_files.assert_called_once_with(science_dir='/custom/path') 226 | 227 | @patch('labrat.cli.FileOrganizer') 228 | def test_organize_keyword(mock_organizer_class, runner): 229 | """Test organizing files by keyword.""" 230 | mock_organizer = MagicMock() 231 | mock_organizer_class.return_value = mock_organizer 232 | 233 | result = runner.invoke(main, [ 234 | 'organize', '--keyword', 'project_alpha' 235 | ]) 236 | 237 | assert result.exit_code == 0 238 | assert "✓ Organized" in result.output 239 | assert "keyword 'project_alpha'" in result.output 240 | mock_organizer.move_specific_files.assert_called_once_with(keyword='project_alpha') 241 | 242 | @patch('labrat.cli.FileOrganizer') 243 | def test_organize_pictures_and_videos(mock_organizer_class, runner): 244 | """Test organizing pictures and videos.""" 245 | mock_organizer = MagicMock() 246 | mock_organizer_class.return_value = mock_organizer 247 | 248 | result = runner.invoke(main, ['organize', '--pictures', '--videos']) 249 | 250 | assert result.exit_code == 0 251 | assert "✓ Organized" in result.output 252 | mock_organizer.organize_files.assert_called_once() 253 | 254 | @patch('labrat.cli.FileOrganizer') 255 | def test_organize_archives(mock_organizer_class, runner): 256 | """Test organizing archive files.""" 257 | mock_organizer = MagicMock() 258 | mock_organizer_class.return_value = mock_organizer 259 | 260 | result = runner.invoke(main, ['organize', '--archives']) 261 | 262 | assert result.exit_code == 0 263 | assert "✓ Organized" in result.output 264 | assert "archives" in result.output 265 | mock_organizer.organize_archives.assert_called_once() 266 | 267 | @patch('labrat.cli.FileOrganizer') 268 | def test_organize_all(mock_organizer_class, runner): 269 | """Test organizing all file types.""" 270 | mock_organizer = MagicMock() 271 | mock_organizer_class.return_value = mock_organizer 272 | 273 | result = runner.invoke(main, ['organize', '--all']) 274 | 275 | assert result.exit_code == 0 276 | assert "✓ Organized" in result.output 277 | assert "all files" in result.output 278 | mock_organizer.organize_all.assert_called_once() 279 | 280 | def test_organize_no_options(runner): 281 | """Test that organize command requires at least one option.""" 282 | result = runner.invoke(main, ['organize']) 283 | 284 | assert result.exit_code != 0 285 | assert "Error: Specify at least one organization option" in result.output 286 | 287 | @patch('labrat.cli.FileOrganizer') 288 | def test_organize_multiple_options(mock_organizer_class, runner): 289 | """Test organizing with multiple options combined.""" 290 | mock_organizer = MagicMock() 291 | mock_organizer_class.return_value = mock_organizer 292 | 293 | result = runner.invoke(main, [ 294 | 'organize', '--science', '--pictures', '--keyword', 'test' 295 | ]) 296 | 297 | assert result.exit_code == 0 298 | assert "✓ Organized" in result.output 299 | mock_organizer.organize_science_files.assert_called_once() 300 | mock_organizer.organize_files.assert_called_once() 301 | mock_organizer.move_specific_files.assert_called_once_with(keyword='test') 302 | 303 | @patch('labrat.cli.FileOrganizer') 304 | def test_organize_error_handling(mock_organizer_class, runner): 305 | """Test error handling when organization fails.""" 306 | mock_organizer = MagicMock() 307 | mock_organizer.organize_science_files.side_effect = PermissionError("Permission denied") 308 | mock_organizer_class.return_value = mock_organizer 309 | 310 | result = runner.invoke(main, ['organize', '--science']) 311 | 312 | assert result.exit_code != 0 313 | assert "✗ Error organizing files" in result.output 314 | -------------------------------------------------------------------------------- /labrat/project/projectmanager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | from pathlib import Path 4 | from datetime import datetime 5 | from logzero import logger 6 | from cookiecutter.main import cookiecutter 7 | from labrat.filemanager.archive import Archiver 8 | from labrat.utils import get_labrat_dir 9 | 10 | 11 | class ProjectManager: 12 | @property 13 | def labrat_file(self): 14 | """ 15 | Get the path to the .labrat config file. 16 | 17 | Returns: 18 | Path: Path to config.json inside the .labrat directory. 19 | """ 20 | return get_labrat_dir() / "config.json" 21 | 22 | def __init__(self, username=None): 23 | """ 24 | Initialize the project manager with a default username. 25 | 26 | Args: 27 | username (str, optional): The user's name. Defaults to the value in .labrat or 'default_user'. 28 | """ 29 | self.username = username or self._get_default_username() 30 | logger.info(f"ProjectManager initialized with username: {self.username}") 31 | self.project_templates = self._load_templates() 32 | 33 | def _initialize_labrat_file(self, default_templates): 34 | """ 35 | Initialize the .labrat file with default templates and structure. 36 | 37 | Args: 38 | default_templates (dict): A dictionary of default project templates. 39 | """ 40 | initial_data = { 41 | "project_manager": self.username, 42 | "projects": [], 43 | "templates": default_templates, 44 | } 45 | try: 46 | with self.labrat_file.open("w") as f: 47 | json.dump(initial_data, f, indent=4) 48 | logger.info("Initialized .labrat file successfully.") 49 | except Exception as e: 50 | logger.error(f"Failed to initialize .labrat file: {e}") 51 | raise 52 | 53 | def _load_templates(self): 54 | """ 55 | Load project templates from the .labrat file or initialize with defaults. 56 | 57 | Returns: 58 | dict: A dictionary of project templates. 59 | """ 60 | default_templates = { 61 | "computational-biology": "https://github.com/sdhutchins/cookiecutter-computational-biology", 62 | "data-science": "https://github.com/drivendataorg/cookiecutter-data-science", 63 | "andymcdgeo-streamlit": "https://github.com/andymcdgeo/cookiecutter-streamlit" 64 | 65 | } 66 | 67 | if self.labrat_file.exists(): 68 | try: 69 | with self.labrat_file.open("r") as f: 70 | data = json.load(f) 71 | templates = data.get("templates", {}) 72 | if not templates: 73 | logger.info("No templates found in .labrat file. Initializing with defaults.") 74 | data["templates"] = default_templates 75 | with self.labrat_file.open("w") as wf: 76 | json.dump(data, wf, indent=4) 77 | logger.debug(f"Loaded {len(data['templates'])} templates from .labrat file.") 78 | return data["templates"] 79 | except (json.JSONDecodeError, ValueError) as e: 80 | logger.warning(f"Malformed or empty .labrat file. Reinitializing: {e}") 81 | except Exception as e: 82 | logger.error(f"Failed to load templates from .labrat file: {e}") 83 | else: 84 | logger.info("No .labrat file found. Initializing with default templates.") 85 | self._initialize_labrat_file(default_templates) 86 | 87 | return default_templates 88 | 89 | def _save_templates(self): 90 | """ 91 | Save the current project templates to the .labrat file. 92 | """ 93 | if self.labrat_file.exists(): 94 | try: 95 | with self.labrat_file.open("r") as f: 96 | data = json.load(f) 97 | except Exception as e: 98 | logger.error(f"Failed to read existing .labrat file: {e}") 99 | data = {} 100 | 101 | data["templates"] = self.project_templates 102 | 103 | try: 104 | with self.labrat_file.open("w") as f: 105 | json.dump(data, f, indent=4) 106 | logger.info("Templates saved successfully to .labrat file.") 107 | except Exception as e: 108 | logger.error(f"Failed to save templates to .labrat file: {e}") 109 | raise 110 | 111 | def _get_default_username(self): 112 | """ 113 | Retrieve the default project manager username from the .labrat file. 114 | 115 | Returns: 116 | str: The default username. 117 | """ 118 | if self.labrat_file.exists(): 119 | try: 120 | with self.labrat_file.open("r") as f: 121 | data = json.load(f) 122 | logger.debug("Default username retrieved from .labrat file.") 123 | return data.get("project_manager", "default_user") 124 | except Exception as e: 125 | logger.error(f"Failed to read .labrat file for default username: {e}") 126 | logger.warning("No .labrat file found. Using default username: 'default_user'") 127 | return "default_user" 128 | 129 | def _remove_project_metadata(self, project_path): 130 | """ 131 | Remove a project's metadata from the .labrat file. 132 | 133 | Args: 134 | project_path (str): The path of the project to remove. 135 | """ 136 | if not self.labrat_file.exists(): 137 | logger.warning(".labrat file does not exist. No metadata to update.") 138 | return 139 | 140 | try: 141 | with self.labrat_file.open("r") as f: 142 | data = json.load(f) 143 | 144 | projects = data.get("projects", []) 145 | # Filter out the project with matching path, keeping only valid dict entries 146 | updated_projects = [ 147 | p for p in projects 148 | if isinstance(p, dict) and p.get("path") != project_path 149 | ] 150 | 151 | if len(projects) == len(updated_projects): 152 | logger.warning(f"No metadata found for project at path: {project_path}") 153 | else: 154 | data["projects"] = updated_projects 155 | with self.labrat_file.open("w") as f: 156 | json.dump(data, f, indent=4) 157 | logger.info(f"Metadata for project at '{project_path}' removed successfully.") 158 | except Exception as e: 159 | logger.error(f"Failed to update .labrat file: {e}") 160 | raise 161 | 162 | def _update_labrat_file(self, project_data): 163 | """ 164 | Update the .labrat file with the new or updated project metadata. 165 | 166 | Args: 167 | project_data (dict): The metadata of the project to add/update. 168 | """ 169 | if not self.labrat_file.exists(): 170 | logger.warning(".labrat file not found. Initializing.") 171 | self._initialize_labrat_file(self._load_templates()) 172 | 173 | try: 174 | with self.labrat_file.open("r") as f: 175 | data = json.load(f) 176 | except (json.JSONDecodeError, ValueError): 177 | logger.warning("Malformed .labrat file. Reinitializing.") 178 | data = {"project_manager": self.username, "projects": [], "templates": self.project_templates} 179 | except Exception as e: 180 | logger.error(f"Failed to read existing .labrat file: {e}") 181 | raise 182 | 183 | # Update or add the project 184 | # Check if project already exists by comparing paths 185 | projects = data.get("projects", []) 186 | for idx, project in enumerate(projects): 187 | # Use .get() to safely access path, handle non-dict entries 188 | if isinstance(project, dict) and project.get("path") == project_data["path"]: 189 | projects[idx] = project_data 190 | logger.debug(f"Updated metadata for project: {project_data['name']}") 191 | break 192 | else: 193 | # Project not found, add as new entry 194 | projects.append(project_data) 195 | logger.debug(f"Added new project to .labrat file: {project_data['name']}") 196 | 197 | data["projects"] = projects 198 | 199 | try: 200 | with self.labrat_file.open("w") as f: 201 | json.dump(data, f, indent=4) 202 | logger.info(f".labrat file updated successfully.") 203 | except Exception as e: 204 | logger.error(f"Failed to update .labrat file: {e}") 205 | raise 206 | 207 | def new_project(self, project_type, project_name, project_path, description): 208 | """ 209 | Create a new project and update its metadata in the .labrat file. 210 | 211 | Args: 212 | project_type (str): The type of project. 213 | project_name (str): The name of your project. 214 | project_path (str): The destination path of the project. 215 | description (str): A description of the project. 216 | 217 | Raises: 218 | ValueError: If the project type is not valid. 219 | """ 220 | if project_type not in self.project_templates: 221 | logger.error(f"Invalid project type: {project_type}") 222 | raise ValueError( 223 | f"{project_type} is not a valid project type. " 224 | f"Available types: {', '.join(self.project_templates.keys())}" 225 | ) 226 | 227 | # Sanitize project name by replacing spaces with underscores 228 | # This ensures compatibility with filesystem naming conventions 229 | sanitized_name = project_name.replace(" ", "_") 230 | # Resolve the path to absolute form, handling relative paths and symlinks 231 | validated_path = Path(project_path).resolve() 232 | 233 | # Create the base directory if it doesn't exist 234 | # parents=True creates any necessary parent directories 235 | if not validated_path.exists(): 236 | validated_path.mkdir(parents=True) 237 | logger.info(f"Created base directory: {validated_path}") 238 | else: 239 | logger.info(f"Using existing directory: {validated_path}") 240 | 241 | # Generate project structure with cookiecutter 242 | # cookiecutter uses templates to scaffold project directories 243 | project_template = self.project_templates[project_type] 244 | # Build context dictionary for cookiecutter template variables 245 | # These values will be substituted into the template 246 | project_context = { 247 | "full_name": self.username, 248 | "project_name": sanitized_name, # Sanitized version for filesystem 249 | "raw_project_name": project_name, # Original name for display 250 | "project_short_description": description, 251 | } 252 | 253 | try: 254 | logger.info(f"Using cookiecutter template: {project_template}") 255 | # Use the returned path from cookiecutter 256 | # no_input=True prevents interactive prompts, using only provided context 257 | # output_dir specifies where to create the project (inside validated_path) 258 | actual_project_path = Path( 259 | cookiecutter( 260 | template=project_template, 261 | no_input=True, 262 | extra_context=project_context, 263 | output_dir=str(validated_path), 264 | ) 265 | ) 266 | logger.info(f"Cookiecutter created project directory: {actual_project_path}") 267 | except Exception as e: 268 | # Re-raise exception after logging to preserve original traceback 269 | logger.error(f"Failed to generate project structure: {e}") 270 | raise 271 | 272 | # Create ISO format timestamp for metadata 273 | # ISO format is standardized and sortable (YYYY-MM-DDTHH:MM:SS) 274 | now = datetime.now().isoformat() 275 | # Build project metadata dictionary for storage in .labrat file 276 | project_data = { 277 | "name": actual_project_path.name, # Use the name generated by cookiecutter 278 | "path": str(actual_project_path), # Full absolute path to the project directory 279 | "description": description, 280 | "created_at": now, # Initial creation timestamp 281 | "last_modified": now, # Initially same as created_at 282 | "project_type": project_type, 283 | } 284 | 285 | # Update the global .labrat file with new project metadata 286 | self._update_labrat_file(project_data) 287 | 288 | logger.info(f"Project '{actual_project_path.name}' created successfully at {actual_project_path}") 289 | 290 | def list_projects(self): 291 | """ 292 | List all projects stored in the .labrat file. 293 | 294 | Returns: 295 | list: A list of project metadata dictionaries. 296 | """ 297 | if self.labrat_file.exists(): 298 | try: 299 | with self.labrat_file.open("r") as f: 300 | data = json.load(f) 301 | logger.debug("Retrieved %d projects from .labrat file.", 302 | len(data.get('projects', []))) 303 | return data.get("projects", []) 304 | except Exception as e: 305 | logger.error("Failed to read .labrat file: %s", e) 306 | logger.warning("No projects found in .labrat file.") 307 | return [] 308 | 309 | def update_project(self, project_path): 310 | """ 311 | Update the last modified timestamp for an existing project. 312 | 313 | Args: 314 | project_path (str): The path to the project to update. 315 | """ 316 | projects = self.list_projects() 317 | resolved_path = str(Path(project_path).resolve()) 318 | for project in projects: 319 | # Use .get() to safely access path and name, handle non-dict entries 320 | if isinstance(project, dict) and project.get("path") == resolved_path: 321 | project["last_modified"] = datetime.now().isoformat() 322 | self._update_labrat_file(project) 323 | logger.info("Updated project '%s' last modified timestamp.", project.get('name', 'unknown')) 324 | return 325 | 326 | logger.warning(f"No project found at path: {project_path}") 327 | 328 | def list_project_files(self, project_path): 329 | """ 330 | List all files within the given project directory. 331 | 332 | Args: 333 | project_path (str): The path to the project. 334 | 335 | Returns: 336 | list: A list of file paths within the project directory. 337 | """ 338 | # Resolve path to handle relative paths and symlinks 339 | validated_path = Path(project_path).resolve() 340 | 341 | # Validate that path exists and is a directory 342 | if not validated_path.exists() or not validated_path.is_dir(): 343 | logger.error(f"Invalid project path: {validated_path}") 344 | return [] 345 | 346 | file_list = [] 347 | try: 348 | # rglob("*") recursively searches all subdirectories 349 | # Filter to include only files (not directories) 350 | file_list = [str(file) for file in validated_path.rglob("*") if file.is_file()] 351 | logger.info(f"Found {len(file_list)} files in project directory: {validated_path}") 352 | except Exception as e: 353 | # Handle permission errors or other filesystem issues 354 | logger.error(f"Failed to list files in project directory: {e}") 355 | return file_list 356 | 357 | def archive_project(self, project_path, archive_base_dir): 358 | """ 359 | Archive a project directory to a timestamped archive folder. 360 | 361 | Args: 362 | project_path (str or Path): The path to the project directory. 363 | archive_base_dir (str or Path): The base directory for storing archives. 364 | 365 | Returns: 366 | str: Path to the created archive directory. 367 | """ 368 | project_path = Path(project_path).resolve() 369 | 370 | if not project_path.exists() or not project_path.is_dir(): 371 | logger.error(f"Invalid project path: {project_path}") 372 | raise ValueError(f"Project path '{project_path}' does not exist or is not a directory.") 373 | 374 | # Extract project name from path for archive naming 375 | project_name = project_path.name 376 | # Generate timestamped archive directory path 377 | archive_dir = Archiver.get_archive_dir(archive_base_dir, project_name) 378 | 379 | # Perform the archive operation 380 | # This copies the project directory and creates a zip file 381 | archive = Archiver(source_dir=project_path, archive_dir=archive_dir) 382 | archive.archive() 383 | 384 | logger.info(f"Archive completed for project '{project_name}'.") 385 | return str(archive_dir) 386 | 387 | def add_template(self, name, url): 388 | """ 389 | Add a new project template. 390 | 391 | Args: 392 | name (str): The name of the template. 393 | url (str): The repository URL of the template. 394 | 395 | Raises: 396 | ValueError: If the template name already exists. 397 | """ 398 | if name in self.project_templates: 399 | logger.error(f"Template '{name}' already exists.") 400 | raise ValueError(f"Template '{name}' already exists.") 401 | 402 | self.project_templates[name] = url 403 | self._save_templates() 404 | logger.info(f"Added new template: {name} -> {url}") 405 | 406 | def delete_project(self, project_path, archive_base_dir): 407 | """ 408 | Archive and delete a project directory. 409 | 410 | Args: 411 | project_path (str or Path): The path to the project directory. 412 | archive_base_dir (str or Path): The base directory for storing archives. 413 | 414 | Raises: 415 | ValueError: If the project path does not exist or is not a directory. 416 | """ 417 | project_path = Path(project_path).resolve() 418 | 419 | if not project_path.exists() or not project_path.is_dir(): 420 | logger.error(f"Invalid project path: {project_path}") 421 | raise ValueError(f"Project path '{project_path}' does not exist or is not a directory.") 422 | 423 | project_name = project_path.name 424 | 425 | # Archive the project before deletion to preserve data 426 | # This ensures the project can be recovered if needed 427 | logger.info(f"Archiving project '{project_name}' before deletion.") 428 | archive_dir = self.archive_project(project_path, archive_base_dir) 429 | 430 | # Delete the project directory and all its contents 431 | # shutil.rmtree removes the directory tree recursively 432 | try: 433 | shutil.rmtree(project_path) 434 | logger.info(f"Project '{project_name}' deleted successfully from: {project_path}") 435 | except Exception as e: 436 | # Re-raise exception after logging to preserve original traceback 437 | logger.error(f"Failed to delete project '{project_name}': {e}") 438 | raise 439 | 440 | # Remove the project metadata from .labrat file 441 | # This cleans up the project registry 442 | self._remove_project_metadata(str(project_path)) 443 | logger.info(f"Project '{project_name}' metadata removed from .labrat.") 444 | 445 | return archive_dir 446 | --------------------------------------------------------------------------------