From 4c4d386cca55d39362a391d30f6a97258dde9e72 Mon Sep 17 00:00:00 2001 From: DataHearth Date: Wed, 7 Dec 2022 18:49:39 +0000 Subject: [PATCH] feat: add more init variables --- src/gitignore_python.txt | 171 +++++++++++++++++++++++++++++++++ src/init.rs | 171 --------------------------------- src/main.rs | 98 +++++++++++++++++-- src/pyproject.rs | 203 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 464 insertions(+), 179 deletions(-) create mode 100644 src/gitignore_python.txt delete mode 100644 src/init.rs create mode 100644 src/pyproject.rs diff --git a/src/gitignore_python.txt b/src/gitignore_python.txt new file mode 100644 index 0000000..a78a441 --- /dev/null +++ b/src/gitignore_python.txt @@ -0,0 +1,171 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + + +# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file diff --git a/src/init.rs b/src/init.rs deleted file mode 100644 index 6e24fd0..0000000 --- a/src/init.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::{collections::HashMap, env}; - -use anyhow::Result; -use dialoguer::{theme::ColorfulTheme, Input}; -use regex::Regex; -use serde::Serialize; -use toml::Value; - -#[derive(Debug, Serialize)] -#[serde(rename_all(serialize = "kebab-case"))] -pub struct Pyproject { - pub build_system: BuildSystem, - pub project: Project, -} - -#[derive(Default, Debug, Serialize)] -#[serde(rename_all(serialize = "kebab-case"))] -pub struct BuildSystem { - pub requires: Vec, - pub build_backend: String, -} - -#[derive(Debug, Serialize)] -pub enum Contributor { - Flat(String), - Complex { name: String, email: String }, -} - -#[derive(Default, Debug, Serialize)] -#[serde(rename_all(serialize = "kebab-case"))] -pub struct Project { - pub name: String, - pub version: String, - - #[serde(skip_serializing_if = "String::is_empty")] - pub description: String, - #[serde(skip_serializing_if = "String::is_empty")] - pub readme: String, - #[serde(skip_serializing_if = "String::is_empty")] - pub requires_python: String, - #[serde(skip_serializing_if = "String::is_empty")] - pub license: String, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub authors: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub maintainers: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub keywords: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub classifiers: Vec, - - #[serde(flatten)] - pub additional_fields: HashMap, -} - -pub fn ask_user_inputs(minimum: bool) -> Result { - let mut project = Project::default(); - let mut build_system = BuildSystem::default(); - let theme = ColorfulTheme::default(); - - build_system.requires = Input::::with_theme(&theme) - .with_prompt("build dependencies (comma separated)") - .default("setuptools,wheel".to_string()) - .interact_text()? - .split(',') - .filter(|v| !v.is_empty()) - .map(|v| v.to_string()) - .collect(); - build_system.build_backend = Input::with_theme(&theme) - .with_prompt("build back-end") - .default("setuptools.build_meta".to_string()) - .interact_text()?; - - project.name = Input::with_theme(&theme) - .with_prompt("project name") - .default( - env::current_dir()? - .file_name() - .expect("invalid current directory") - .to_str() - .expect("invalid UTF-8 cwd name") - .to_string(), - ) - .interact_text()?; - project.version = Input::with_theme(&theme) - .with_prompt("version") - .default("0.1.0".to_string()) - .interact_text()?; - - if !minimum { - project.description = Input::with_theme(&theme) - .with_prompt("description") - .allow_empty(true) - .interact_text()?; - project.readme = Input::with_theme(&theme) - .with_prompt("readme") - .allow_empty(true) - .interact_text()?; - project.requires_python = Input::with_theme(&theme) - .with_prompt("minimum python version") - .allow_empty(true) - .interact_text()?; - project.license = Input::with_theme(&theme) - .with_prompt("license") - .allow_empty(true) - .interact_text()?; - project.authors = Input::::with_theme(&theme) - .with_prompt( - r#"authors (e.g: "Antoine Langlois";"name="Antoine L",email="email@domain.net"")"#, - ) - .allow_empty(true) - .interact_text()? - .split(';') - .filter(|v| !v.is_empty()) - .map(|v| parse_contributor(v)) - .collect(); - project.maintainers = Input::::with_theme(&theme) - .with_prompt( - r#"maintainers (e.g: "Antoine Langlois";"name="Antoine L",email="email@domain.net"")"#, - ) - .allow_empty(true) - .interact_text()? - .split(';') - .filter(|v| !v.is_empty()) - .map(|v| parse_contributor(v)) - .collect(); - project.keywords = Input::::with_theme(&theme) - .with_prompt("keywords (e.g: KEYW1;KEYW2)") - .allow_empty(true) - .interact_text()? - .split(';') - .filter(|v| !v.is_empty()) - .map(|v| v.to_string()) - .collect(); - project.classifiers = Input::::with_theme(&theme) - .with_prompt("classifiers (e.g: CLASS1;CLASS2)") - .allow_empty(true) - .interact_text()? - .split(';') - .filter(|v| !v.is_empty()) - .map(|v| v.to_string()) - .collect(); - } - - Ok(Pyproject { - build_system, - project, - }) -} - -fn parse_contributor(contributor: &str) -> Contributor { - let name_regex = Regex::new(r#"name="([\w\s\-\.]*)""#).expect("invalid name regex expression"); - let email_regex = - Regex::new(r#"email="([\w\s\-\.@]*)""#).expect("invalid email regex expression"); - - let name = name_regex.captures(contributor); - let email = email_regex.captures(contributor); - - if name.is_some() && email.is_some() { - Contributor::Complex { - name: name.unwrap().get(1).unwrap().as_str().to_string(), - email: email.unwrap().get(1).unwrap().as_str().to_string(), - } - } else if let Some(name_match) = name { - Contributor::Flat(name_match.get(1).unwrap().as_str().to_string()) - } else if let Some(email_match) = email { - Contributor::Flat(email_match.get(1).unwrap().as_str().to_string()) - } else { - Contributor::Flat(contributor.to_string()) - } -} diff --git a/src/main.rs b/src/main.rs index 2152a50..0df4f1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,45 @@ -use std::{fs::write, path::PathBuf}; +use std::{ + env, + fs::{self, File}, + path::PathBuf, + process::Command, +}; use anyhow::Result; use clap::Parser; -use init::ask_user_inputs; -use toml::toml; +use pyproject::Pyproject; -mod init; +mod pyproject; + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +enum Layout { + Src, + Flat, +} #[derive(clap::Parser)] #[command(author, version, about, long_about = None)] struct Args { + /// Output will be more verbose #[arg(short, long, default_value_t = false)] verbose: bool, - /// Initialize a new python directory with minimal fields for "pyproject.toml" + /// Initialize with minimal fields for "pyproject.toml" #[arg(short, long, default_value_t = true)] minimum: bool, + /// Initialize folder with a git repository + #[arg(long, default_value_t = true)] + git: bool, + + /// Initialize a new virtual environment with given name in initialized directory + #[arg(long)] + venv: Option, + + /// Define a layout for your project (https://setuptools.pypa.io/en/latest/userguide/package_discovery.html) + #[arg(long, value_enum)] + layout: Option, + #[command(subcommand)] subcommands: Subcommands, } @@ -31,12 +54,71 @@ fn main() -> Result<()> { let args = Args::parse(); match args.subcommands { - Subcommands::New { folder } => todo!(), + Subcommands::New { folder } => { + fs::create_dir(&folder)?; + let folder = folder.canonicalize()?; + + initialize_folder(folder, args.minimum, args.layout, args.venv, args.git)?; + } Subcommands::Init {} => { - let pyproject = ask_user_inputs(args.minimum)?; - write("pyproject.toml", toml::to_vec(&pyproject)?)?; + let folder = env::current_dir()?; + + initialize_folder(folder, args.minimum, args.layout, args.venv, args.git)?; } }; Ok(()) } + +fn initialize_folder( + folder: PathBuf, + minimum: bool, + layout: Option, + venv: Option, + git: bool, +) -> Result<()> { + // todo: avoid clone and maybe find a better way + let mut pypro = Pyproject::new(folder.clone(), minimum); + + pypro.ask_inputs()?; + let project_name = pypro.get_project_name(); + + pypro.create_file()?; + + if let Some(layout) = layout { + let layout_inner_path = match layout { + Layout::Src => { + let inner_path = folder.join(format!("src/{project_name}")); + fs::create_dir_all(&inner_path)?; + + inner_path + } + Layout::Flat => { + let inner_path = folder.join(project_name); + fs::create_dir(&inner_path)?; + + inner_path + } + }; + + File::create(layout_inner_path.join("__init__.py"))?; + } + if let Some(venv) = venv { + Command::new("python3") + .args(&["-m", "venv", &venv]) + .current_dir(&folder) + .status()?; + } + if git { + Command::new("git") + .args(&["init"]) + .current_dir(&folder) + .status()?; + fs::write( + folder.join(".gitignore"), + include_bytes!("gitignore_python.txt"), + )?; + } + + Ok(()) +} diff --git a/src/pyproject.rs b/src/pyproject.rs new file mode 100644 index 0000000..ff096a1 --- /dev/null +++ b/src/pyproject.rs @@ -0,0 +1,203 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{anyhow, Result}; +use dialoguer::{theme::ColorfulTheme, Input}; +use regex::Regex; +use serde::Serialize; + +const PYPROJECT: &str = "pyproject.toml"; + +#[derive(Debug, Serialize)] +#[serde(rename_all(serialize = "kebab-case"))] +pub struct Pyproject { + #[serde(skip_serializing)] + folder: PathBuf, + #[serde(skip_serializing)] + minimum: bool, + pub build_system: BuildSystem, + pub project: Project, +} + +impl Pyproject { + pub fn new(folder: PathBuf, minimum: bool) -> Self { + Pyproject { + folder, + minimum, + build_system: BuildSystem::default(), + project: Project::default(), + } + } + + /// Ask user inputs to create a basic (or not) pyproject.toml file. + /// `minimum` will only ask for the `build-system` fields and the `project.name` + /// and `project.version` fields + pub fn ask_inputs(&mut self) -> Result<()> { + let theme = ColorfulTheme::default(); + + self.build_system.requires = Input::::with_theme(&theme) + .with_prompt("build dependencies (comma separated)") + .default("setuptools,wheel".to_string()) + .interact_text()? + .split(',') + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect(); + self.build_system.build_backend = Input::with_theme(&theme) + .with_prompt("build back-end") + .default("setuptools.build_meta".to_string()) + .interact_text()?; + + // ? might want to switch to OsString instead, if the Serialize macro supports it + let folder = match self + .folder + .file_name() + .ok_or(anyhow!("project can't terminate by \"..\""))? + .to_str() + { + Some(v) => Some(v.to_string()), + None => None, + }; + let mut input: Input = Input::with_theme(&theme); + if let Some(folder) = folder { + self.project.name = input + .with_prompt("project name") + .default(folder) + .interact_text()?; + } else { + self.project.name = input.with_prompt("project name").interact_text()?; + } + + self.project.version = Input::with_theme(&theme) + .with_prompt("version") + .default("0.1.0".to_string()) + .interact_text()?; + + if !self.minimum { + self.project.description = Input::with_theme(&theme) + .with_prompt("description") + .allow_empty(true) + .interact_text()?; + self.project.readme = Input::with_theme(&theme) + .with_prompt("readme") + .allow_empty(true) + .interact_text()?; + self.project.requires_python = Input::with_theme(&theme) + .with_prompt("minimum python version") + .allow_empty(true) + .interact_text()?; + self.project.license = Input::with_theme(&theme) + .with_prompt("license") + .allow_empty(true) + .interact_text()?; + self.project.authors = Input::::with_theme(&theme) + .with_prompt( + r#"authors (e.g: "Antoine Langlois";"name="Antoine L",email="email@domain.net"")"#, + ) + .allow_empty(true) + .interact_text()? + .split(';') + .filter(|v| !v.is_empty()) + .map(|v| self.parse_contributor(v)) + .collect(); + self.project.maintainers = Input::::with_theme(&theme) + .with_prompt( + r#"maintainers (e.g: "Antoine Langlois";"name="Antoine L",email="email@domain.net"")"#, + ) + .allow_empty(true) + .interact_text()? + .split(';') + .filter(|v| !v.is_empty()) + .map(|v| self.parse_contributor(v)) + .collect(); + self.project.keywords = Input::::with_theme(&theme) + .with_prompt("keywords (e.g: KEYW1;KEYW2)") + .allow_empty(true) + .interact_text()? + .split(';') + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect(); + self.project.classifiers = Input::::with_theme(&theme) + .with_prompt("classifiers (e.g: CLASS1;CLASS2)") + .allow_empty(true) + .interact_text()? + .split(';') + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect(); + } + + Ok(()) + } + + fn parse_contributor(&self, contributor: &str) -> Contributor { + let name_regex = + Regex::new(r#"name="([\w\s\-\.]*)""#).expect("invalid name regex expression"); + let email_regex = + Regex::new(r#"email="([\w\s\-\.@]*)""#).expect("invalid email regex expression"); + + let name = name_regex.captures(contributor); + let email = email_regex.captures(contributor); + + if name.is_some() && email.is_some() { + Contributor::Complex { + name: name.unwrap().get(1).unwrap().as_str().to_string(), + email: email.unwrap().get(1).unwrap().as_str().to_string(), + } + } else if let Some(name_match) = name { + Contributor::Flat(name_match.get(1).unwrap().as_str().to_string()) + } else if let Some(email_match) = email { + Contributor::Flat(email_match.get(1).unwrap().as_str().to_string()) + } else { + Contributor::Flat(contributor.to_string()) + } + } + + pub fn get_project_name(&self) -> String { + // ? clone or maybe something else ? + self.project.name.clone() + } + /// Consume self and write everything to a `self.folder/pyproject.toml` + pub fn create_file(self) -> Result<()> { + fs::write(self.folder.join(PYPROJECT), toml::to_vec(&self)?)?; + + Ok(()) + } +} + +#[derive(Default, Debug, Serialize)] +#[serde(rename_all(serialize = "kebab-case"))] +pub struct BuildSystem { + pub requires: Vec, + pub build_backend: String, +} + +#[derive(Debug, Serialize)] +pub enum Contributor { + Flat(String), + Complex { name: String, email: String }, +} + +#[derive(Default, Debug, Serialize)] +#[serde(rename_all(serialize = "kebab-case"))] +pub struct Project { + pub name: String, + pub version: String, + + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub readme: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub requires_python: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub license: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub authors: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub maintainers: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub keywords: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub classifiers: Vec, +}