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 +51 -0
- aeris-0.1.0/README.md +35 -0
- aeris-0.1.0/aeris/__init__.py +0 -0
- aeris-0.1.0/aeris/cli.py +128 -0
- aeris-0.1.0/aeris/config.py +31 -0
- aeris-0.1.0/aeris/database/engine.py +10 -0
- aeris-0.1.0/aeris/database/models.py +19 -0
- aeris-0.1.0/aeris/web/AGENTS.md +7 -0
- aeris-0.1.0/aeris/web/main.py +0 -0
- aeris-0.1.0/pyproject.toml +62 -0
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
|
aeris-0.1.0/aeris/cli.py
ADDED
|
@@ -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,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)
|
|
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
|