jobty 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.
jobty-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.3
2
+ Name: jobty
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: El John
6
+ Author-email: El John <bongaeljohn07@gmail.com>
7
+ Requires-Dist: pydantic>=2.12.5
8
+ Requires-Dist: sqlalchemy>=2.0.49
9
+ Requires-Dist: typer>=0.24.1
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Jobty
14
+
15
+ A simple command-line application to track your job applications.
16
+
17
+ This project helps you manage every job application in one place with commands for listing, adding, updating, and deleting job application entries. I originally created this project for myself to keep track of my job applications.
18
+
19
+ ## What this project is
20
+
21
+ `jobty` is a Python CLI app built to track job applications you submit.
22
+ It stores application details locally and provides a quick interface to:
23
+
24
+ - add new job application entry
25
+ - view all saved application entries
26
+ - inspect a single job application entry
27
+ - update a single job application entry
28
+ - delete a single job application entry
29
+
30
+ ## Requirements
31
+
32
+ - Python 3.13 or newer
33
+ - `pydantic`
34
+ - `questionary`
35
+ - `typer`
36
+
37
+ ## How to clone and run it
38
+
39
+ > [!IMPORTANT]
40
+ > You must already have [uv](https://docs.astral.sh/uv/) installed on your machine. If not, you can check out the documentation [here](https://docs.astral.sh/uv/getting-started/installation/).
41
+
42
+ 1. Clone the repository:
43
+
44
+ ```bash
45
+ git clone https://github.com/eljohn316/jobty.git
46
+ cd jobty
47
+ ```
48
+
49
+ 2. Create and activate a virtual environment with uv:
50
+
51
+ ```bash
52
+ uv venv .venv
53
+ source .venv/bin/activate
54
+ ```
55
+
56
+ 3. Installation
57
+
58
+ ```bash
59
+ uv sync
60
+ ```
61
+
62
+ ## Example commands
63
+
64
+ - List all job applications entries:
65
+
66
+ ```bash
67
+ jobty list
68
+ ```
69
+
70
+ - View a single application by ID:
71
+
72
+ ```bash
73
+ jobty list-one <job_id>
74
+ ```
75
+
76
+ - Add a new application:
77
+
78
+ ```bash
79
+ jobty add
80
+ ```
81
+
82
+ - Update an existing application:
83
+
84
+ ```bash
85
+ jobty update <job_id>
86
+ ```
87
+
88
+ - Delete an application:
89
+
90
+ ```bash
91
+ jobty delete <job_id>
92
+ ```
93
+
94
+ ## Notes
95
+
96
+ When run, the CLI initializes the local job applications storage automatically, so you can start adding applications right away.
jobty-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Jobty
2
+
3
+ A simple command-line application to track your job applications.
4
+
5
+ This project helps you manage every job application in one place with commands for listing, adding, updating, and deleting job application entries. I originally created this project for myself to keep track of my job applications.
6
+
7
+ ## What this project is
8
+
9
+ `jobty` is a Python CLI app built to track job applications you submit.
10
+ It stores application details locally and provides a quick interface to:
11
+
12
+ - add new job application entry
13
+ - view all saved application entries
14
+ - inspect a single job application entry
15
+ - update a single job application entry
16
+ - delete a single job application entry
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.13 or newer
21
+ - `pydantic`
22
+ - `questionary`
23
+ - `typer`
24
+
25
+ ## How to clone and run it
26
+
27
+ > [!IMPORTANT]
28
+ > You must already have [uv](https://docs.astral.sh/uv/) installed on your machine. If not, you can check out the documentation [here](https://docs.astral.sh/uv/getting-started/installation/).
29
+
30
+ 1. Clone the repository:
31
+
32
+ ```bash
33
+ git clone https://github.com/eljohn316/jobty.git
34
+ cd jobty
35
+ ```
36
+
37
+ 2. Create and activate a virtual environment with uv:
38
+
39
+ ```bash
40
+ uv venv .venv
41
+ source .venv/bin/activate
42
+ ```
43
+
44
+ 3. Installation
45
+
46
+ ```bash
47
+ uv sync
48
+ ```
49
+
50
+ ## Example commands
51
+
52
+ - List all job applications entries:
53
+
54
+ ```bash
55
+ jobty list
56
+ ```
57
+
58
+ - View a single application by ID:
59
+
60
+ ```bash
61
+ jobty list-one <job_id>
62
+ ```
63
+
64
+ - Add a new application:
65
+
66
+ ```bash
67
+ jobty add
68
+ ```
69
+
70
+ - Update an existing application:
71
+
72
+ ```bash
73
+ jobty update <job_id>
74
+ ```
75
+
76
+ - Delete an application:
77
+
78
+ ```bash
79
+ jobty delete <job_id>
80
+ ```
81
+
82
+ ## Notes
83
+
84
+ When run, the CLI initializes the local job applications storage automatically, so you can start adding applications right away.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "jobty"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "El John", email = "bongaeljohn07@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "pydantic>=2.12.5",
12
+ "sqlalchemy>=2.0.49",
13
+ "typer>=0.24.1",
14
+ ]
15
+
16
+ [project.scripts]
17
+ jobty = "jobty.main:app"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.9.15,<0.10.0"]
21
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,14 @@
1
+ from enum import Enum
2
+ from typing import Literal
3
+
4
+
5
+ class Colors(Enum):
6
+ red = "#ef4444"
7
+ emerald = "#34d399"
8
+ amber = "#fbbf24"
9
+ gray = "#9ca3af"
10
+
11
+
12
+ Arrangement = Literal["Onsite", "Hybrid", "Remote"]
13
+
14
+ Status = Literal["Applied", "Interview", "Hired", "Rejected"]
@@ -0,0 +1,26 @@
1
+ from contextlib import contextmanager
2
+
3
+ from sqlalchemy import create_engine
4
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
5
+
6
+ SQLALCHEMY_DATABASE_URL = "sqlite:///jobs.db"
7
+
8
+ engine = create_engine(SQLALCHEMY_DATABASE_URL)
9
+
10
+ Session = sessionmaker(bind=engine, autoflush=False)
11
+
12
+
13
+ class Base(DeclarativeBase):
14
+ pass
15
+
16
+
17
+ @contextmanager
18
+ def get_db_session():
19
+ db = Session()
20
+ try:
21
+ yield db
22
+ except Exception:
23
+ db.rollback()
24
+ raise
25
+ finally:
26
+ db.close()
@@ -0,0 +1,186 @@
1
+ from datetime import datetime
2
+ from enum import Enum
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from typer import Argument, Option
7
+
8
+ from .constants import Arrangement, Status
9
+ from .database import Base, engine
10
+ from .print_helpers import print_job, print_jobs
11
+ from .schemas import JobCreate, JobDetails, JobUpdate
12
+ from .services import create_job, delete_job, get_job, get_jobs, update_job
13
+ from .validations import validate_model
14
+
15
+ Base.metadata.create_all(bind=engine)
16
+
17
+ app = typer.Typer(no_args_is_help=True)
18
+
19
+
20
+ class FilterStatus(str, Enum):
21
+ applied = "Applied"
22
+ interview = "Interview"
23
+ hired = "Hired"
24
+ rejected = "Rejected"
25
+
26
+
27
+ class WorkArrangement(str, Enum):
28
+ onsite = "Onsite"
29
+ hybrid = "Hybrid"
30
+ remote = "Remote"
31
+
32
+
33
+ @app.command()
34
+ def list(
35
+ job_id: Annotated[
36
+ int,
37
+ Argument(help="The ID of the Job to list", metavar="job_id"),
38
+ ] = None,
39
+ status: Annotated[list[FilterStatus], Option(help="Filter by status")] = [],
40
+ work_arrangement: Annotated[
41
+ list[WorkArrangement], Option(help="Filter by work arrangement")
42
+ ] = [],
43
+ ):
44
+ """List all job application entries or a single job application entry if job id is provided."""
45
+ if job_id:
46
+ job = get_job(job_id)
47
+ if job is None:
48
+ typer.echo("Job application not found")
49
+ return
50
+ job_details = JobDetails(**job.__dict__)
51
+ print_job(job_details)
52
+ else:
53
+ jobs = get_jobs(
54
+ status=[s.value for s in status],
55
+ work_arrangement=[w.value for w in work_arrangement],
56
+ )
57
+
58
+ if len(jobs) == 0:
59
+ typer.echo("You have no job applications yet.")
60
+ return
61
+
62
+ print_jobs(jobs)
63
+
64
+
65
+ @app.command()
66
+ def add(
67
+ role: Annotated[str, Option(help="Job role")],
68
+ company: Annotated[str, Option(help="Company name")],
69
+ location: Annotated[str, Option(help="Location")],
70
+ work_arrangement: Annotated[Arrangement, Option(help="Work arrangement")],
71
+ status: Annotated[Status, Option(help="Application status")] = "Applied",
72
+ source_link: Annotated[str | None, Option(help="Source link")] = None,
73
+ interview_date: Annotated[
74
+ datetime | None,
75
+ Option(
76
+ help="Interview date",
77
+ formats=["%m-%d-%Y"],
78
+ ),
79
+ ] = None,
80
+ interview_time: Annotated[
81
+ datetime | None,
82
+ Option(
83
+ help="Interview time",
84
+ formats=["%I:%M %p"],
85
+ ),
86
+ ] = None,
87
+ ):
88
+ """
89
+ Add job application entry.
90
+ """
91
+ job_create_model = validate_model(
92
+ JobCreate,
93
+ {
94
+ "role": role,
95
+ "company": company,
96
+ "location": location,
97
+ "work_arrangement": work_arrangement,
98
+ "status": status,
99
+ "source_link": source_link,
100
+ "interview_date": interview_date.date() if interview_date else None,
101
+ "interview_time": interview_time.time() if interview_time else None,
102
+ },
103
+ )
104
+ create_job(job_create_model)
105
+ typer.echo("Job application added")
106
+
107
+
108
+ @app.command()
109
+ def update(
110
+ job_id: Annotated[
111
+ int,
112
+ Argument(help="The ID of the Job to update", metavar="job_id"),
113
+ ],
114
+ role: Annotated[str | None, Option(help="Job role")] = None,
115
+ company: Annotated[str | None, Option(help="Company name")] = None,
116
+ location: Annotated[str | None, Option(help="Location")] = None,
117
+ work_arrangement: Annotated[
118
+ Arrangement | None, Option(help="Work arrangement")
119
+ ] = None,
120
+ status: Annotated[Status | None, Option(help="Application status")] = None,
121
+ source_link: Annotated[str | None, Option(help="Source link")] = None,
122
+ interview_date: Annotated[
123
+ datetime | None,
124
+ Option(
125
+ help="Interview date",
126
+ formats=["%m-%d-%Y"],
127
+ ),
128
+ ] = None,
129
+ interview_time: Annotated[
130
+ datetime | None,
131
+ Option(
132
+ help="Interview time",
133
+ formats=["%I:%M %p"],
134
+ ),
135
+ ] = None,
136
+ ):
137
+ """
138
+ Update an existing job application entry.
139
+ """
140
+ if (
141
+ role is None
142
+ and company is None
143
+ and location is None
144
+ and work_arrangement is None
145
+ and status is None
146
+ and source_link is None
147
+ and interview_date is None
148
+ and interview_time is None
149
+ ):
150
+ typer.echo("No fields to update")
151
+ return
152
+
153
+ job_update = JobUpdate.model_validate(
154
+ {
155
+ "role": role,
156
+ "company": company,
157
+ "location": location,
158
+ "work_arrangement": work_arrangement,
159
+ "status": status,
160
+ "source_link": source_link,
161
+ "interview_date": interview_date.date() if interview_date else None,
162
+ "interview_time": interview_time.time() if interview_time else None,
163
+ }
164
+ )
165
+
166
+ updated_job_id = update_job(job_id, job_update)
167
+ typer.echo(f"Job #{updated_job_id} updated")
168
+
169
+
170
+ @app.command()
171
+ def delete(
172
+ job_id: Annotated[
173
+ int,
174
+ Argument(help="The ID of the Job to delete", metavar="job_id"),
175
+ ],
176
+ ):
177
+ """
178
+ Delete an existing job application entry.
179
+ """
180
+ job = get_job(job_id)
181
+ if job is None:
182
+ typer.echo("Job application not found")
183
+ return
184
+
185
+ delete_job(job)
186
+ typer.echo(f"Job #{job_id} deleted")
@@ -0,0 +1,35 @@
1
+ from datetime import date, datetime, time
2
+ from typing import Literal
3
+
4
+ from sqlalchemy import Date, DateTime, Integer, String, Time, UniqueConstraint
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from .database import Base
8
+
9
+
10
+ class Job(Base):
11
+ __tablename__ = "jobs"
12
+
13
+ id: Mapped[int] = mapped_column(
14
+ Integer,
15
+ primary_key=True,
16
+ index=True,
17
+ autoincrement=True,
18
+ )
19
+ created_at: Mapped[datetime] = mapped_column(
20
+ DateTime, default=lambda: datetime.now()
21
+ )
22
+ role: Mapped[str] = mapped_column(String, nullable=False)
23
+ company: Mapped[str] = mapped_column(String, nullable=False)
24
+ location: Mapped[str] = mapped_column(String, nullable=False)
25
+ work_arrangement: Mapped[Literal["Onsite", "Hybrid", "Remote"]] = mapped_column(
26
+ String, nullable=False
27
+ )
28
+ status: Mapped[Literal["Applied", "Interview", "Hired", "Rejected"]] = (
29
+ mapped_column(String, nullable=False)
30
+ )
31
+ source_link: Mapped[str | None] = mapped_column(String, nullable=True)
32
+ interview_date: Mapped[date | None] = mapped_column(Date, nullable=True)
33
+ interview_time: Mapped[time | None] = mapped_column(Time, nullable=True)
34
+
35
+ __table_args__ = (UniqueConstraint("role", "company", name="unq_role_company"),)
@@ -0,0 +1,97 @@
1
+ from datetime import datetime
2
+ from typing import Literal, Sequence, Tuple
3
+
4
+ import rich
5
+ from pydantic import ValidationError
6
+ from rich.table import Table
7
+ from sqlalchemy import Row
8
+
9
+ from .constants import Colors
10
+ from .schemas import JobDetails, Status
11
+
12
+ JobRow = Tuple[
13
+ int,
14
+ datetime,
15
+ str,
16
+ str,
17
+ Literal["Applied", "Interview", "Hired", "Rejected"],
18
+ Literal["Onsite", "Hybrid", "Remote"],
19
+ ]
20
+
21
+
22
+ def print_jobs(
23
+ jobs: Sequence[Row[JobRow]],
24
+ ):
25
+ table = Table(box=rich.box.SIMPLE)
26
+
27
+ table.add_column("ID", header_style=Colors.emerald.value)
28
+ table.add_column("Role", header_style=Colors.emerald.value)
29
+ table.add_column("Company", header_style=Colors.emerald.value)
30
+ table.add_column(
31
+ "Work arrangement", header_style=Colors.emerald.value, justify="center"
32
+ )
33
+ table.add_column("Status", header_style=Colors.emerald.value, justify="right")
34
+ table.add_column("Added on", header_style=Colors.emerald.value, justify="right")
35
+
36
+ for job_id, created_at, role, company, status, work_arrangement in jobs:
37
+ table.add_row(
38
+ f"[{Colors.gray.value}]{str(job_id)}",
39
+ f"[bold]{role}",
40
+ company,
41
+ work_arrangement,
42
+ render_status(status),
43
+ f"[{Colors.gray.value}]{created_at.strftime('%b %-d, %Y - %I:%M %p')}",
44
+ )
45
+
46
+ rich.print(table)
47
+
48
+
49
+ def print_job(job_details_model: JobDetails):
50
+ job = job_details_model.model_dump(exclude_none=True)
51
+
52
+ table = Table(box=rich.box.SIMPLE, show_header=False)
53
+ table.add_column(style=f"bold {Colors.emerald.value}")
54
+ table.add_row("ID", str(job.get("id")))
55
+ table.add_row("Role", f"[bold]{job.get('role')}")
56
+ table.add_row("Company", job.get("company"))
57
+ table.add_row("Location", job.get("location"))
58
+ table.add_row("Work arrangement", job.get("work_arrangement"))
59
+ table.add_row("Status", render_status(job.get("status")))
60
+ table.add_row(
61
+ "Source link",
62
+ job.get("source_link", f"[{Colors.gray.value}]Unset"),
63
+ )
64
+ table.add_row(
65
+ "Interview date",
66
+ job.get("interview_date", f"[{Colors.gray.value}]Unset"),
67
+ )
68
+ table.add_row(
69
+ "Interview time",
70
+ job.get("interview_time", f"[{Colors.gray.value}]Unset"),
71
+ )
72
+ table.add_row(
73
+ "Added on",
74
+ job.get("created_at"),
75
+ )
76
+ rich.print(table)
77
+
78
+
79
+ def print_validation_errors(exception: ValidationError):
80
+ rich.print()
81
+ rich.print(f"[{Colors.red.value}]Validation error")
82
+ for error in exception.errors():
83
+ field: str = error["loc"][0]
84
+ field = (" ".join(field.split("_"))).capitalize()
85
+ message = error["msg"]
86
+ rich.print(f"{field} - [{Colors.gray.value}]{message}")
87
+
88
+
89
+ def render_status(status: Status):
90
+ if status == "Applied":
91
+ return f"[yellow]{status}[/]"
92
+ if status == "Interview":
93
+ return f"[cyan]{status}[/]"
94
+ if status == "Hired":
95
+ return f"[green]{status}[/]"
96
+ if status == "Rejected":
97
+ return f"[red]{status}[/]"
@@ -0,0 +1,62 @@
1
+ from datetime import date, datetime, time
2
+
3
+ from pydantic import BaseModel, ConfigDict, field_serializer
4
+
5
+ from .constants import Arrangement, Status
6
+
7
+
8
+ class JobBaseModel(BaseModel):
9
+ model_config = ConfigDict(
10
+ extra="ignore",
11
+ str_strip_whitespace=True,
12
+ str_min_length=1,
13
+ validate_default=True,
14
+ use_enum_values=True,
15
+ )
16
+
17
+
18
+ class JobBase(JobBaseModel):
19
+ role: str
20
+ company: str
21
+ location: str
22
+ work_arrangement: Arrangement
23
+ status: Status
24
+ source_link: str | None = None
25
+ interview_date: date | None = None
26
+ interview_time: time | None = None
27
+
28
+ @field_serializer("interview_date", mode="plain")
29
+ def ser_interview_date(self, value: date | None):
30
+ if isinstance(value, date):
31
+ return value.strftime("%b %d, %Y")
32
+ return value
33
+
34
+ @field_serializer("interview_time", mode="plain")
35
+ def ser_interview_time(self, value: time | None):
36
+ if isinstance(value, time):
37
+ return value.strftime("%I:%M %p")
38
+ return value
39
+
40
+
41
+ class JobCreate(JobBase):
42
+ pass
43
+
44
+
45
+ class JobDetails(JobBase):
46
+ id: int
47
+ created_at: datetime
48
+
49
+ @field_serializer("created_at", mode="plain")
50
+ def ser_created_at(self, value: datetime):
51
+ return value.strftime("%b %-d, %Y - %I:%M %p")
52
+
53
+
54
+ class JobUpdate(JobBaseModel):
55
+ role: str | None = None
56
+ company: str | None = None
57
+ location: str | None = None
58
+ work_arrangement: Arrangement | None
59
+ status: Status | None = None
60
+ source_link: str | None = None
61
+ interview_date: date | None = None
62
+ interview_time: time | None = None
@@ -0,0 +1,80 @@
1
+ import typer
2
+ from sqlalchemy import select
3
+ from sqlalchemy.exc import IntegrityError
4
+
5
+ from . import models
6
+ from .database import get_db_session
7
+ from .schemas import JobCreate, JobUpdate
8
+
9
+
10
+ def create_job(job: JobCreate):
11
+ with get_db_session() as session:
12
+ try:
13
+ new_job = models.Job(
14
+ role=job.role,
15
+ company=job.company,
16
+ location=job.location,
17
+ work_arrangement=job.work_arrangement,
18
+ status=job.status,
19
+ source_link=job.source_link,
20
+ interview_date=job.interview_date,
21
+ interview_time=job.interview_time,
22
+ )
23
+ session.add(new_job)
24
+ session.commit()
25
+ session.refresh(new_job)
26
+ return new_job
27
+ except IntegrityError:
28
+ typer.echo("Job already exists")
29
+ raise typer.Exit()
30
+
31
+
32
+ def get_job(job_id: int):
33
+ with get_db_session() as session:
34
+ query = select(models.Job).where(models.Job.id == job_id)
35
+ job = session.execute(query).scalars().first()
36
+ return job
37
+
38
+
39
+ def get_jobs(status: list[str], work_arrangement: list[str]):
40
+ with get_db_session() as session:
41
+ query = select(
42
+ models.Job.id,
43
+ models.Job.created_at,
44
+ models.Job.role,
45
+ models.Job.company,
46
+ models.Job.status,
47
+ models.Job.work_arrangement,
48
+ )
49
+
50
+ if status:
51
+ query = query.where(models.Job.status.in_(status))
52
+
53
+ if work_arrangement:
54
+ query = query.where(models.Job.work_arrangement.in_(work_arrangement))
55
+
56
+ jobs = session.execute(query).all()
57
+ return jobs
58
+
59
+
60
+ def delete_job(job: models.Job):
61
+ with get_db_session() as session:
62
+ session.delete(job)
63
+ session.commit()
64
+
65
+
66
+ def update_job(job_id: int, job_update: JobUpdate):
67
+ with get_db_session() as session:
68
+ job = session.get(models.Job, job_id)
69
+ if job is None:
70
+ typer.echo("Job application not found")
71
+ raise typer.Exit()
72
+
73
+ job_payload = job_update.model_dump(exclude_none=True)
74
+ for field, value in job_payload.items():
75
+ setattr(job, field, value)
76
+
77
+ session.commit()
78
+ session.refresh(job)
79
+
80
+ return job.id
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ import typer
4
+ from pydantic import BaseModel, ValidationError
5
+
6
+ from .print_helpers import print_validation_errors
7
+
8
+
9
+ def validate_model(model: type[BaseModel], payload: dict[str, Any]):
10
+ try:
11
+ validated_model = model.model_validate(payload)
12
+ return validated_model
13
+ except ValidationError as e:
14
+ print_validation_errors(e)
15
+ raise typer.Exit()