aeris 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aeris-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.3
2
+ Name: aeris
3
+ Version: 0.1.0
4
+ Summary: Jotting down notes.
5
+ Author: Lucas Maystre
6
+ Author-email: Lucas Maystre <lucas@maystre.ch>
7
+ Requires-Dist: dateparser>=1.4.0
8
+ Requires-Dist: fastapi>=0.136.1
9
+ Requires-Dist: psycopg[binary]>=3.3.3
10
+ Requires-Dist: pydantic>=2.13.3
11
+ Requires-Dist: pydantic-settings>=2.14.0
12
+ Requires-Dist: sqlalchemy>=2.0.49
13
+ Requires-Dist: typer>=0.24.2
14
+ Requires-Python: >=3.13
15
+ Description-Content-Type: text/markdown
16
+
17
+ # aeris
18
+
19
+ A command-line tool for jotting down notes, backed by a PostgreSQL database.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install aeris
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Create `~/.aeris.json`:
30
+
31
+ ```json
32
+ {
33
+ "database_url": "postgresql+psycopg://user:password@localhost/aeris"
34
+ }
35
+ ```
36
+
37
+ Then initialize the database:
38
+
39
+ ```bash
40
+ aeris reset-db
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ aeris add # open $EDITOR to write a note
47
+ aeris list # list recent notes
48
+ aeris list --last "2 hours" # notes from the last 2 hours
49
+ aeris display [id] # display note(s) in full
50
+ aeris delete <id> # delete a note
51
+ ```
aeris-0.1.0/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # aeris
2
+
3
+ A command-line tool for jotting down notes, backed by a PostgreSQL database.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install aeris
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Create `~/.aeris.json`:
14
+
15
+ ```json
16
+ {
17
+ "database_url": "postgresql+psycopg://user:password@localhost/aeris"
18
+ }
19
+ ```
20
+
21
+ Then initialize the database:
22
+
23
+ ```bash
24
+ aeris reset-db
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ aeris add # open $EDITOR to write a note
31
+ aeris list # list recent notes
32
+ aeris list --last "2 hours" # notes from the last 2 hours
33
+ aeris display [id] # display note(s) in full
34
+ aeris delete <id> # delete a note
35
+ ```
File without changes
@@ -0,0 +1,128 @@
1
+ import os
2
+ import subprocess
3
+ import tempfile
4
+ from datetime import datetime
5
+
6
+ import dateparser
7
+ import typer
8
+ from sqlalchemy.orm import Session
9
+
10
+ from aeris.database.engine import engine
11
+ from aeris.database.models import Base, Note
12
+
13
+ app = typer.Typer()
14
+
15
+
16
+ @app.callback()
17
+ def main() -> None:
18
+ pass
19
+
20
+
21
+ def _parse_last(value: str) -> datetime:
22
+ result = dateparser.parse(f"{value} ago", settings={"RETURN_AS_TIMEZONE_AWARE": True})
23
+ if result is None:
24
+ typer.echo(f"Could not parse time expression: '{value}'")
25
+ raise typer.Exit(1)
26
+ return result
27
+
28
+
29
+ @app.command()
30
+ def add() -> None:
31
+ editor = os.environ.get("EDITOR", "vi")
32
+ fd, path = tempfile.mkstemp(suffix=".txt")
33
+ try:
34
+ os.close(fd)
35
+ subprocess.run([editor, path], check=True)
36
+ with open(path) as f:
37
+ content = f.read().strip()
38
+ finally:
39
+ os.unlink(path)
40
+
41
+ if not content:
42
+ typer.echo("Empty note, nothing saved.")
43
+ raise typer.Exit()
44
+
45
+ with Session(engine) as session:
46
+ session.add(Note(content=content))
47
+ session.commit()
48
+
49
+ typer.echo("Note saved.")
50
+
51
+
52
+ @app.command(name="list")
53
+ def list_notes(
54
+ limit: int = typer.Option(30, "--limit"),
55
+ last: str | None = typer.Option(None, "--last"),
56
+ ) -> None:
57
+ with Session(engine) as session:
58
+ query = session.query(Note).order_by(Note.created_at.asc())
59
+ if last is not None:
60
+ query = query.filter(Note.created_at >= _parse_last(last))
61
+ notes = query.limit(limit).all()
62
+
63
+ if not notes:
64
+ return
65
+
66
+ id_width = max(len(str(n.id)) for n in notes)
67
+ for note in notes:
68
+ content = note.content.replace("\n", " ")
69
+ if len(content) > 100:
70
+ content = content[:99] + "…"
71
+ created = note.created_at.astimezone().strftime("%Y-%m-%d %H:%M:%S")
72
+ typer.echo(f"{note.id:<{id_width}} {created} {content}")
73
+
74
+
75
+ @app.command()
76
+ def display(
77
+ id: int | None = typer.Argument(None),
78
+ limit: int = typer.Option(30, "--limit"),
79
+ last: str | None = typer.Option(None, "--last"),
80
+ ) -> None:
81
+ with Session(engine) as session:
82
+ if id is not None:
83
+ note = session.get(Note, id)
84
+ if note is None:
85
+ typer.echo(f"No note with id {id}.")
86
+ raise typer.Exit(1)
87
+ notes = [note]
88
+ else:
89
+ query = session.query(Note).order_by(Note.created_at.asc())
90
+ if last is not None:
91
+ query = query.filter(Note.created_at >= _parse_last(last))
92
+ notes = query.limit(limit).all()
93
+
94
+ for i, note in enumerate(notes):
95
+ created = note.created_at.astimezone().strftime("%Y-%m-%d %H:%M:%S")
96
+ typer.echo(f"# {note.id} {created}")
97
+ typer.echo("")
98
+ typer.echo(note.content.rstrip())
99
+ if i < len(notes) - 1:
100
+ typer.echo("")
101
+ typer.echo("")
102
+
103
+
104
+ @app.command()
105
+ def delete(id: int) -> None:
106
+ with Session(engine) as session:
107
+ note = session.get(Note, id)
108
+ if note is None:
109
+ typer.echo(f"No note with id {id}.")
110
+ raise typer.Exit(1)
111
+ session.delete(note)
112
+ session.commit()
113
+ typer.echo("Deleted.")
114
+
115
+
116
+ @app.command(name="reset-db")
117
+ def reset_db() -> None:
118
+ confirm = typer.prompt("Type 'reset' to confirm")
119
+ if confirm != "reset":
120
+ typer.echo("Aborted.")
121
+ raise typer.Exit()
122
+ Base.metadata.drop_all(engine)
123
+ Base.metadata.create_all(engine)
124
+ typer.echo("Done.")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ app()
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic_settings import (
4
+ BaseSettings,
5
+ JsonConfigSettingsSource,
6
+ PydanticBaseSettingsSource,
7
+ SettingsConfigDict,
8
+ )
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ model_config = SettingsConfigDict(
13
+ json_file=Path.home() / ".aeris.json",
14
+ extra="forbid",
15
+ )
16
+
17
+ database_url: str
18
+
19
+ @classmethod
20
+ def settings_customise_sources(
21
+ cls,
22
+ settings_cls: type[BaseSettings],
23
+ init_settings: PydanticBaseSettingsSource,
24
+ env_settings: PydanticBaseSettingsSource,
25
+ dotenv_settings: PydanticBaseSettingsSource,
26
+ file_secret_settings: PydanticBaseSettingsSource,
27
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
28
+ return (JsonConfigSettingsSource(settings_cls),)
29
+
30
+
31
+ settings = Settings() # type: ignore[call-arg] # fields are populated from ~/.aeris.json
@@ -0,0 +1,10 @@
1
+ from sqlalchemy import create_engine
2
+
3
+ from aeris.config import settings
4
+ from aeris.database.models import Base
5
+
6
+ engine = create_engine(settings.database_url)
7
+
8
+
9
+ def create_tables() -> None:
10
+ Base.metadata.create_all(engine)
@@ -0,0 +1,19 @@
1
+ from datetime import datetime
2
+
3
+ from sqlalchemy import DateTime, Text, func
4
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
5
+
6
+
7
+ class Base(DeclarativeBase):
8
+ pass
9
+
10
+
11
+ class Note(Base):
12
+ __tablename__ = "note"
13
+
14
+ id: Mapped[int] = mapped_column(primary_key=True)
15
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
16
+ updated_at: Mapped[datetime] = mapped_column(
17
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
18
+ )
19
+ content: Mapped[str] = mapped_column(Text)
@@ -0,0 +1,7 @@
1
+ This web app uses:
2
+
3
+ - FastAPI
4
+ - htmx (v2)
5
+ - Tailwind CSS (v4)
6
+
7
+ Pay attention to the versions that are used.
File without changes
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "aeris"
3
+ version = "0.1.0"
4
+ description = "Jotting down notes."
5
+ authors = [
6
+ {name = "Lucas Maystre", email = "lucas@maystre.ch"},
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "dateparser>=1.4.0",
12
+ "fastapi>=0.136.1",
13
+ "psycopg[binary]>=3.3.3",
14
+ "pydantic>=2.13.3",
15
+ "pydantic-settings>=2.14.0",
16
+ "sqlalchemy>=2.0.49",
17
+ "typer>=0.24.2",
18
+ ]
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pyright>=1.1.402",
23
+ "ruff>=0.11.13",
24
+ ]
25
+
26
+ [project.scripts]
27
+ aeris = "aeris.cli:app"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.11.7,<0.12"]
31
+ build-backend = "uv_build"
32
+
33
+ [tool.uv.build-backend]
34
+ module-name = "aeris"
35
+ module-root = ""
36
+
37
+ [tool.ruff]
38
+ line-length = 100 # Default is 88.
39
+
40
+ [tool.ruff.format]
41
+ line-ending = "lf" # Default is "auto".
42
+
43
+ [tool.ruff.lint]
44
+ extend-select = ["W", "I"] # Adds warnings & imports.
45
+
46
+ [tool.pyright]
47
+ deprecateTypingAliases = true
48
+ reportDuplicateImport = true
49
+ reportFunctionMemberAccess = "error"
50
+ reportImportCycles = "error"
51
+ reportIncompatibleMethodOverride = "error"
52
+ reportInvalidStringEscapeSequence = "error"
53
+ reportMatchNotExhaustive = "error"
54
+ reportMissingImports = "error"
55
+ reportPropertyTypeMismatch = "error"
56
+ reportUnknownMemberType = false
57
+ reportUnknownVariableType = false
58
+ reportUntypedBaseClass = "error"
59
+ strictDictionaryInference = true
60
+ strictListInference = true
61
+ strictSetInference = true
62
+ reportMissingParameterType = true