Compare commits

...

7 Commits
v0.1.0 ... main

Author SHA1 Message Date
DataHearth e8ee999ece
feat: add licenses and refactor
Rust / test (push) Failing after 50s Details
2023-06-28 18:32:18 +02:00
Antoine Langlois f41157cab9 Update 'LICENSE' 2023-05-24 10:04:58 +02:00
DataHearth 7160203089
chore: update cargo registry token instruction
continuous-integration/drone/push Build is passing Details
2022-12-15 00:27:50 +01:00
DataHearth 2e24584843
chore: add badges
continuous-integration/drone/push Build is passing Details
2022-12-07 22:40:07 +00:00
DataHearth ce0e418d91
chore: add drone CI/CD
continuous-integration/drone/push Build is passing Details
2022-12-07 22:20:14 +00:00
DataHearth 5776424399
chore: v0.1.1 2022-12-07 19:55:35 +00:00
DataHearth bee822fe32
fix: correct complete flag 2022-12-07 19:53:56 +00:00
12 changed files with 1157 additions and 126 deletions

View File

@ -0,0 +1,31 @@
name: Deploy
on:
push:
tags:
- "v*.*.*"
jobs:
deploy-crate:
runs-on: debian-rust
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Publish crate
run: cargo publish
deploy-wheel:
runs-on: debian-rust
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Publish wheel
env:
MATURIN_REPOSITORY_URL: ${{ secrets.PIP_REPOSITORY }}
MATURIN_USERNAME: datahearth
MATURIN_PYPI_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
pip install maturin
maturin publish

22
.gitea/workflows/rust.yml Normal file
View File

@ -0,0 +1,22 @@
name: Rust
on:
push:
branches:
- "main"
pull_request:
branches:
- "main"
env:
CARGO_TERM_COLOR: always
jobs:
test:
runs-on: debian-rust
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Run tests
run: cargo test --verbose

3
.gitignore vendored
View File

@ -1 +1,2 @@
/target
target
demo_pynit

837
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "pynit"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
license = "GPL-3.0-or-later"
@ -14,5 +14,6 @@ anyhow = "1.0"
clap = { version = "4.0", features = ["derive"] }
dialoguer = "0.10"
regex = "1.7"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] }
toml = "0.5"

View File

@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
CLI tool to create a python project with setuptools standards and add additional tools if needed.
Copyright (C) 2023 Antoine Langlois "antoine.l@antoine-langlois.net
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
pynit Copyright (C) 2023 Antoine Langlois "antoine.l@antoine-langlois.net"
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.

View File

@ -1 +1,88 @@
# pynit
[![License](https://img.shields.io/crates/l/pynit)](https://gitea.antoine-langlois.net/DataHearth/pynit/src/branch/main/LICENSE)
[![Version](https://img.shields.io/crates/v/pynit)](https://crates.io/crates/pynit)
---
`pynit` speeds up the process of creating a new python project. It can initialise the project with `git`,
a virtual environment (using the `venv` module) and creating a [basic folder structure](https://setuptools.pypa.io/en/latest/userguide/package_discovery.html) if wanted.
## Usage
```bash
Small CLI tool to initialize a python project
Usage: pynit [OPTIONS] <COMMAND>
Commands:
new
init
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Output will be more verbose
-c, --complete Initialize with minimal fields for "pyproject.toml"
--git Initialize folder with a git repository
--venv <VENV> Initialize a new virtual environment with given name in initialized directory
--layout <LAYOUT> Define a layout for your project (https://setuptools.pypa.io/en/latest/userguide/package_discovery.html) [possible values: src, flat]
-h, --help Print help information
-V, --version Print version information
```
2 subcommands are available: `new` and `init`. Each of them have the same flags (defined globally).
### Global flags
#### --verbose
Adding this flag will make the `STDOUT` more verbose.
NOTE: the flag doesn't do anything currently as logging is not yet implemented.
#### -c/--complete
`--complete` allows you to control how much questions will be asked during the initialisation of the `pyproject.toml`. By default, only required fields will be asked: `build-sytem` section, `project.name` and `project.version`.
#### --git
Initialise a git repository
#### --venv
Add a virtual environment to your python project with a given `name`.
#### --layout
Add a default popular folder structure to your project.
Two options are available: `flat` and `src`.
For more information, check out [this setuptools section](https://setuptools.pypa.io/en/latest/userguide/package_discovery.html).
### new
`new` acts like `cargo new`. It take one argument which is the project name. Project name will be the folder name and used as default when asking questions to create a basic `pyproject.toml`.
The directory must NOT exist before creating the project.
#### Examples
```bash
$ pynit --git --venv .env --layout flat new my_project
$ exa -a --tree --level=2 my_project
my_project
├── .env
│ ├── ...
├── .git
│ ├── ...
├── .gitignore
├── my_project
│ └── __init__.py
└── pyproject.toml
```
### init
Like `new`, `init` does the same thing. Except it'll initiase the current directory.
NOTE: if a `.gitignore` and/or a `pyproject.toml` is present, they'll be truncated.

View File

@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "pynit"
version = "0.1.0"
version = "0.1.1"
requires-python = ">=3.7"
classifiers = [
"Environment :: Console",

50
src/components.rs Normal file
View File

@ -0,0 +1,50 @@
use anyhow::Result;
use dialoguer::Select;
use dialoguer::{theme::ColorfulTheme, Input};
use std::fmt::Debug;
use std::str::FromStr;
pub fn input<T>(theme: &ColorfulTheme, prompt: &str, empty: bool, default: Option<T>) -> Result<T>
where
T: Clone + FromStr + ToString,
<T as FromStr>::Err: Debug + ToString,
{
let mut input = Input::<T>::with_theme(theme);
input.with_prompt(prompt).allow_empty(empty);
if let Some(default) = default {
input.default(default);
}
Ok(input.interact_text()?)
}
pub fn input_list<F, T>(
theme: &ColorfulTheme,
prompt: &str,
empty: bool,
default: Option<String>,
map_fn: F,
) -> Result<Vec<T>>
where
F: FnMut(&str) -> T,
{
Ok(input(theme, prompt, empty, default)?
.split(';')
.filter(|v| !v.is_empty())
.map(map_fn)
.collect())
}
pub fn select(
theme: &ColorfulTheme,
prompt: &str,
default: usize,
items: &[String],
) -> Result<usize> {
Ok(Select::with_theme(theme)
.with_prompt(prompt)
.default(default)
.items(items)
.interact()?)
}

37
src/license.rs Normal file
View File

@ -0,0 +1,37 @@
use anyhow::Result;
use reqwest::{blocking::Client, header::USER_AGENT};
use serde::{Deserialize, Serialize};
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct LicenseDetails {
#[serde(flatten)]
base_license: License,
pub body: String,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct License {
pub spdx_id: String,
}
pub fn get_license_spdx() -> Result<Vec<String>> {
let licenses = Client::new()
.get(&format!("https://api.github.com/licenses",))
.header(USER_AGENT, format!("pynit-{}", env!("CARGO_PKG_VERSION")))
.send()?
.json::<Vec<License>>()?;
Ok(licenses
.iter()
.map(|license| license.spdx_id.clone())
.collect())
}
pub fn get_license(spdx: String) -> Result<LicenseDetails> {
Ok(Client::new()
.get(&format!("https://api.github.com/licenses/{spdx}"))
.header(USER_AGENT, format!("pynit-{}", env!("CARGO_PKG_VERSION")))
.send()?
.json::<LicenseDetails>()?)
}

View File

@ -2,13 +2,16 @@ use std::{
env,
fs::{self, File},
path::PathBuf,
process::Command,
process::{Command, Stdio},
};
use anyhow::Result;
use clap::Parser;
use license::get_license;
use pyproject::Pyproject;
mod components;
mod license;
mod pyproject;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
@ -25,11 +28,11 @@ struct Args {
verbose: bool,
/// Initialize with minimal fields for "pyproject.toml"
#[arg(short, long, default_value_t = true)]
minimum: bool,
#[arg(short, long)]
complete: bool,
/// Initialize folder with a git repository
#[arg(long, default_value_t = true)]
#[arg(long)]
git: bool,
/// Initialize a new virtual environment with given name in initialized directory
@ -58,12 +61,12 @@ fn main() -> Result<()> {
fs::create_dir(&folder)?;
let folder = folder.canonicalize()?;
initialize_folder(folder, args.minimum, args.layout, args.venv, args.git)?;
initialize_folder(folder, args.complete, args.layout, args.venv, args.git)?;
}
Subcommands::Init {} => {
let folder = env::current_dir()?;
initialize_folder(folder, args.minimum, args.layout, args.venv, args.git)?;
initialize_folder(folder, args.complete, args.layout, args.venv, args.git)?;
}
};
@ -72,17 +75,22 @@ fn main() -> Result<()> {
fn initialize_folder(
folder: PathBuf,
minimum: bool,
complete: bool,
layout: Option<Layout>,
venv: Option<String>,
git: bool,
) -> Result<()> {
// todo: avoid clone and maybe find a better way
let mut pypro = Pyproject::new(folder.clone(), minimum);
let mut pypro = Pyproject::new(folder.clone(), complete);
pypro.ask_inputs()?;
let project_name = pypro.get_project_name();
let project_name = pypro.get_project_name();
fs::write(
folder.join("LICENSE"),
get_license(pypro.get_license_spdx())?.body,
)?;
pypro.create_file()?;
if let Some(layout) = layout {
@ -107,12 +115,14 @@ fn initialize_folder(
Command::new("python3")
.args(&["-m", "venv", &venv])
.current_dir(&folder)
.stdout(Stdio::null())
.status()?;
}
if git {
Command::new("git")
.args(&["init"])
.current_dir(&folder)
.stdout(Stdio::null())
.status()?;
fs::write(
folder.join(".gitignore"),

View File

@ -1,10 +1,15 @@
use std::{fs, path::PathBuf};
use anyhow::{anyhow, Result};
use dialoguer::{theme::ColorfulTheme, Input};
use dialoguer::theme::ColorfulTheme;
use regex::Regex;
use serde::Serialize;
use crate::{
components::{input, input_list, select},
license::get_license_spdx,
};
const PYPROJECT: &str = "pyproject.toml";
#[derive(Debug, Serialize)]
@ -13,16 +18,16 @@ pub struct Pyproject {
#[serde(skip_serializing)]
folder: PathBuf,
#[serde(skip_serializing)]
minimum: bool,
complete: bool,
pub build_system: BuildSystem,
pub project: Project,
}
impl Pyproject {
pub fn new(folder: PathBuf, minimum: bool) -> Self {
pub fn new(folder: PathBuf, complete: bool) -> Self {
Pyproject {
folder,
minimum,
complete,
build_system: BuildSystem::default(),
project: Project::default(),
}
@ -34,98 +39,83 @@ impl Pyproject {
pub fn ask_inputs(&mut self) -> Result<()> {
let theme = ColorfulTheme::default();
self.build_system.requires = Input::<String>::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()?;
self.build_system.requires = input_list(
&theme,
"build dependencies (comma separated)",
false,
Some("setuptools,wheel".to_string()),
|v| v.to_string(),
)?;
self.build_system.build_backend = input::<String>(
&theme,
"build back-end",
false,
Some("setuptools.build_meta".to_string()),
)?;
// ? 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<String> = 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.name = input::<String>(
&theme,
"project name",
false,
match self
.folder
.file_name()
.ok_or(anyhow!("project can't terminate by \"..\""))?
.to_str()
{
Some(v) => Some(v.to_string()),
None => None,
},
)?;
self.project.version =
input::<String>(&theme, "version", false, Some("0.1.0".to_string()))?;
if self.complete {
self.ask_complete(&theme)?;
}
self.project.version = Input::with_theme(&theme)
.with_prompt("version")
.default("0.1.0".to_string())
.interact_text()?;
Ok(())
}
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::<String>::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::<String>::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::<String>::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::<String>::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();
}
fn ask_complete(&mut self, theme: &ColorfulTheme) -> Result<()> {
self.project.description = input::<String>(theme, "description", true, None)?;
self.project.readme = input::<String>(theme, "readme", true, None)?;
self.project.requires_python =
input::<String>(theme, "minimum python version", true, None)?;
let license_spdx = get_license_spdx()?;
let license_index = select(
theme,
"license",
license_spdx
.binary_search(&"MIT".into())
.or(Err(anyhow!("MIT license not found")))?,
&license_spdx[..],
)?;
self.project.license = license_spdx[license_index].clone();
self.project.authors = input_list(
theme,
r#"authors (e.g: "Antoine Langlois";"name="Antoine L",email="email@domain.net"")"#,
true,
None,
|v| self.parse_contributor(v),
)?;
self.project.maintainers = input_list(
theme,
r#"maintainers (e.g: "Antoine Langlois";"name="Antoine L",email="email@domain.net"")"#,
true,
None,
|v| self.parse_contributor(v),
)?;
self.project.keywords =
input_list(theme, "keywords (e.g: KEYW1;KEYW2)", true, None, |v| {
v.to_string()
})?;
self.project.classifiers =
input_list(theme, "classifiers (e.g: CLASS1;CLASS2)", true, None, |v| {
v.to_string()
})?;
Ok(())
}
@ -157,6 +147,11 @@ impl Pyproject {
// ? clone or maybe something else ?
self.project.name.clone()
}
pub fn get_license_spdx(&self) -> String {
self.project.license.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)?)?;