applika-cli 0.1.0__py3-none-any.whl

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.
applika/__init__.py ADDED
File without changes
applika/app.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from applika.commands.applications import applications_app
9
+ from applika.commands.auth import login, logout, whoami
10
+ from applika.commands.skill import skill
11
+ from applika.config import AppConfig, resolve_api_base_url
12
+ from applika.lib.session import SessionStore
13
+
14
+ app = typer.Typer(
15
+ name='applika',
16
+ help='Job application tracker CLI for Applika.dev.',
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ app.add_typer(applications_app, name='applications')
21
+ app.command('skill')(skill)
22
+ app.command('login')(login)
23
+ app.command('logout')(logout)
24
+ app.command('whoami')(whoami)
25
+
26
+
27
+ @app.callback()
28
+ def _root(
29
+ ctx: typer.Context,
30
+ api_base_url: Annotated[
31
+ str | None,
32
+ typer.Option(
33
+ '--api-base-url',
34
+ help='Override the API base URL (default: https://applika.dev/api).',
35
+ envvar='APPLIKA_API_BASE_URL',
36
+ show_default=False,
37
+ ),
38
+ ] = None,
39
+ _version: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ '--version',
43
+ '-v',
44
+ help='Show the version and exit.',
45
+ is_eager=True,
46
+ ),
47
+ ] = False,
48
+ ) -> None:
49
+ if _version:
50
+ typer.echo(version('applika-cli'))
51
+ raise typer.Exit()
52
+ store = SessionStore()
53
+ ctx.ensure_object(dict)
54
+ ctx.obj = AppConfig(
55
+ api_base_url=resolve_api_base_url(api_base_url, store),
56
+ store=store,
57
+ )
File without changes
@@ -0,0 +1,25 @@
1
+ import typer
2
+
3
+ from applika.commands.applications.commands import (
4
+ edit_application,
5
+ list_applications,
6
+ new_application,
7
+ )
8
+
9
+ applications_app = typer.Typer(
10
+ name='applications',
11
+ help='Manage job applications.',
12
+ no_args_is_help=True,
13
+ invoke_without_command=True,
14
+ )
15
+
16
+
17
+ @applications_app.callback()
18
+ def _default(ctx: typer.Context) -> None:
19
+ if ctx.invoked_subcommand is None:
20
+ ctx.invoke(list_applications)
21
+
22
+
23
+ applications_app.command('list')(list_applications)
24
+ applications_app.command('new')(new_application)
25
+ applications_app.command('edit')(edit_application)
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from pydantic import ValidationError
8
+
9
+ from applika.config import AppConfig
10
+ from applika.lib.api import ApiClient, require_session
11
+ from applika.schemas.application import ApplicationCreate, ApplicationUpdate
12
+ from applika.schemas.enums import (
13
+ ApplicationMode,
14
+ Currency,
15
+ ExperienceLevel,
16
+ ModeFilter,
17
+ OutputFormat,
18
+ SalaryPeriod,
19
+ StatusFilter,
20
+ WorkMode,
21
+ )
22
+ from applika.utils.output import (
23
+ print_application_summary,
24
+ render_application_table,
25
+ )
26
+
27
+ from .filter import filter_applications
28
+ from .payloads import ApplicationArgs, build_application_payload
29
+
30
+
31
+ def list_applications(
32
+ ctx: typer.Context,
33
+ cycle_id: Annotated[
34
+ str | None,
35
+ typer.Option('--cycle-id', help='Filter by cycle ID.'),
36
+ ] = None,
37
+ search: Annotated[
38
+ str | None,
39
+ typer.Option(
40
+ '--search',
41
+ help='Search in company name or role (case-insensitive).',
42
+ ),
43
+ ] = None,
44
+ mode: Annotated[
45
+ ModeFilter,
46
+ typer.Option('--mode', help='Filter by application mode.'),
47
+ ] = ModeFilter.ALL,
48
+ status: Annotated[
49
+ StatusFilter,
50
+ typer.Option('--status', help='Filter by application status.'),
51
+ ] = StatusFilter.ALL,
52
+ platform: Annotated[
53
+ str | None,
54
+ typer.Option(
55
+ '--platform', help='Filter by platform name (e.g. LinkedIn).'
56
+ ),
57
+ ] = None,
58
+ from_date: Annotated[
59
+ str | None,
60
+ typer.Option(
61
+ '--from',
62
+ help='Include applications from this date (YYYY-MM-DD, inclusive).',
63
+ ),
64
+ ] = None,
65
+ to_date: Annotated[
66
+ str | None,
67
+ typer.Option(
68
+ '--to',
69
+ help='Include applications up to this date (YYYY-MM-DD, inclusive).',
70
+ ),
71
+ ] = None,
72
+ output_format: Annotated[
73
+ OutputFormat,
74
+ typer.Option(
75
+ '--output-format', help='Output format: table (default) or json.'
76
+ ),
77
+ ] = OutputFormat.TABLE,
78
+ ) -> None:
79
+ """List job applications with optional filters, sorted by date descending."""
80
+ config: AppConfig = ctx.obj
81
+ session = require_session(config.store)
82
+ client = ApiClient(session, config.store)
83
+
84
+ try:
85
+ params = {'cycle_id': cycle_id} if cycle_id else None
86
+ applications = client.get_json('/applications', params=params)
87
+ supports = client.get_json('/supports')
88
+ filtered = filter_applications(
89
+ applications,
90
+ supports,
91
+ search=search,
92
+ mode=str(mode),
93
+ status=str(status),
94
+ platform=platform,
95
+ from_date=from_date,
96
+ to_date=to_date,
97
+ )
98
+
99
+ if output_format == OutputFormat.JSON:
100
+ print(json.dumps(filtered, indent=2, sort_keys=True))
101
+ else:
102
+ render_application_table(filtered, supports)
103
+ finally:
104
+ client.close()
105
+
106
+
107
+ def new_application(
108
+ ctx: typer.Context,
109
+ company: Annotated[
110
+ str,
111
+ typer.Option('--company', help='Company name.'),
112
+ ],
113
+ role: Annotated[
114
+ str,
115
+ typer.Option('--role', help='Job title or role.'),
116
+ ],
117
+ platform: Annotated[
118
+ str,
119
+ typer.Option(
120
+ '--platform', help='Platform name (e.g. LinkedIn, Indeed).'
121
+ ),
122
+ ],
123
+ mode: Annotated[
124
+ ApplicationMode,
125
+ typer.Option('--mode', help='Application mode: active or passive.'),
126
+ ],
127
+ application_date: Annotated[
128
+ str,
129
+ typer.Option('--date', help='Application date (YYYY-MM-DD).'),
130
+ ],
131
+ company_url: Annotated[
132
+ str | None,
133
+ typer.Option('--company-url', help='Company website URL.'),
134
+ ] = None,
135
+ job_url: Annotated[
136
+ str | None,
137
+ typer.Option('--job-url', help='Link to the job posting.'),
138
+ ] = None,
139
+ observation: Annotated[
140
+ str | None,
141
+ typer.Option(
142
+ '--observation', help='Notes or observations about the application.'
143
+ ),
144
+ ] = None,
145
+ expected_salary: Annotated[
146
+ float | None,
147
+ typer.Option('--expected-salary', help='Expected salary amount.'),
148
+ ] = None,
149
+ salary_min: Annotated[
150
+ float | None,
151
+ typer.Option('--salary-min', help='Minimum salary range.'),
152
+ ] = None,
153
+ salary_max: Annotated[
154
+ float | None,
155
+ typer.Option('--salary-max', help='Maximum salary range.'),
156
+ ] = None,
157
+ currency: Annotated[
158
+ Currency | None,
159
+ typer.Option(
160
+ '--currency', help='Salary currency (required when salary is set).'
161
+ ),
162
+ ] = None,
163
+ salary_period: Annotated[
164
+ SalaryPeriod | None,
165
+ typer.Option(
166
+ '--salary-period',
167
+ help='Salary period (required when salary is set).',
168
+ ),
169
+ ] = None,
170
+ experience_level: Annotated[
171
+ ExperienceLevel | None,
172
+ typer.Option('--experience-level', help='Required experience level.'),
173
+ ] = None,
174
+ work_mode: Annotated[
175
+ WorkMode | None,
176
+ typer.Option(
177
+ '--work-mode', help='Work mode: remote, hybrid, or on_site.'
178
+ ),
179
+ ] = None,
180
+ country: Annotated[
181
+ str | None,
182
+ typer.Option('--country', help='Country where the job is located.'),
183
+ ] = None,
184
+ ) -> None:
185
+ """Create a new job application."""
186
+ try:
187
+ ApplicationCreate(
188
+ company=company,
189
+ role=role,
190
+ platform=platform,
191
+ mode=mode,
192
+ application_date=application_date, # type: ignore[arg-type]
193
+ company_url=company_url,
194
+ job_url=job_url,
195
+ observation=observation,
196
+ expected_salary=expected_salary,
197
+ salary_min=salary_min,
198
+ salary_max=salary_max,
199
+ currency=currency,
200
+ salary_period=salary_period,
201
+ experience_level=experience_level,
202
+ work_mode=work_mode,
203
+ country=country,
204
+ )
205
+ except ValidationError as exc:
206
+ for err in exc.errors():
207
+ field = '.'.join(str(loc) for loc in err['loc'])
208
+ typer.echo(f'Error [{field}]: {err["msg"]}', err=True)
209
+ raise typer.Exit(1)
210
+
211
+ config: AppConfig = ctx.obj
212
+ session = require_session(config.store)
213
+ client = ApiClient(session, config.store)
214
+
215
+ try:
216
+ args = ApplicationArgs(
217
+ company=company,
218
+ company_url=company_url,
219
+ role=role,
220
+ platform=platform,
221
+ mode=str(mode),
222
+ application_date=application_date,
223
+ job_url=job_url,
224
+ observation=observation,
225
+ expected_salary=expected_salary,
226
+ salary_min=salary_min,
227
+ salary_max=salary_max,
228
+ currency=str(currency) if currency else None,
229
+ salary_period=str(salary_period) if salary_period else None,
230
+ experience_level=str(experience_level)
231
+ if experience_level
232
+ else None,
233
+ work_mode=str(work_mode) if work_mode else None,
234
+ country=country,
235
+ )
236
+ payload = build_application_payload(client, args, existing=None)
237
+ created = client.post_json('/applications', payload)
238
+ print_application_summary(created, 'Created application')
239
+ finally:
240
+ client.close()
241
+
242
+
243
+ def edit_application(
244
+ ctx: typer.Context,
245
+ application_id: Annotated[
246
+ str,
247
+ typer.Argument(help='ID of the application to edit.'),
248
+ ],
249
+ company: Annotated[
250
+ str | None,
251
+ typer.Option('--company', help='New company name.'),
252
+ ] = None,
253
+ role: Annotated[
254
+ str | None,
255
+ typer.Option('--role', help='New job title or role.'),
256
+ ] = None,
257
+ platform: Annotated[
258
+ str | None,
259
+ typer.Option('--platform', help='New platform name.'),
260
+ ] = None,
261
+ mode: Annotated[
262
+ ApplicationMode | None,
263
+ typer.Option('--mode', help='New application mode.'),
264
+ ] = None,
265
+ application_date: Annotated[
266
+ str | None,
267
+ typer.Option('--date', help='New application date (YYYY-MM-DD).'),
268
+ ] = None,
269
+ company_url: Annotated[
270
+ str | None,
271
+ typer.Option('--company-url', help='New company website URL.'),
272
+ ] = None,
273
+ job_url: Annotated[
274
+ str | None,
275
+ typer.Option('--job-url', help='New link to the job posting.'),
276
+ ] = None,
277
+ observation: Annotated[
278
+ str | None,
279
+ typer.Option('--observation', help='New notes or observations.'),
280
+ ] = None,
281
+ expected_salary: Annotated[
282
+ float | None,
283
+ typer.Option('--expected-salary', help='New expected salary amount.'),
284
+ ] = None,
285
+ salary_min: Annotated[
286
+ float | None,
287
+ typer.Option('--salary-min', help='New minimum salary range.'),
288
+ ] = None,
289
+ salary_max: Annotated[
290
+ float | None,
291
+ typer.Option('--salary-max', help='New maximum salary range.'),
292
+ ] = None,
293
+ currency: Annotated[
294
+ Currency | None,
295
+ typer.Option('--currency', help='New salary currency.'),
296
+ ] = None,
297
+ salary_period: Annotated[
298
+ SalaryPeriod | None,
299
+ typer.Option('--salary-period', help='New salary period.'),
300
+ ] = None,
301
+ experience_level: Annotated[
302
+ ExperienceLevel | None,
303
+ typer.Option(
304
+ '--experience-level', help='New required experience level.'
305
+ ),
306
+ ] = None,
307
+ work_mode: Annotated[
308
+ WorkMode | None,
309
+ typer.Option('--work-mode', help='New work mode.'),
310
+ ] = None,
311
+ country: Annotated[
312
+ str | None,
313
+ typer.Option('--country', help='New country where the job is located.'),
314
+ ] = None,
315
+ clear_job_url: Annotated[
316
+ bool,
317
+ typer.Option('--clear-job-url', help='Remove the job URL.'),
318
+ ] = False,
319
+ clear_observation: Annotated[
320
+ bool,
321
+ typer.Option(
322
+ '--clear-observation', help='Remove the observation note.'
323
+ ),
324
+ ] = False,
325
+ clear_country: Annotated[
326
+ bool,
327
+ typer.Option('--clear-country', help='Remove the country.'),
328
+ ] = False,
329
+ clear_salary: Annotated[
330
+ bool,
331
+ typer.Option('--clear-salary', help='Remove all salary fields.'),
332
+ ] = False,
333
+ ) -> None:
334
+ """Edit an existing job application. Unspecified fields keep their current values."""
335
+ try:
336
+ ApplicationUpdate(
337
+ company=company,
338
+ role=role,
339
+ platform=platform,
340
+ mode=mode,
341
+ application_date=application_date, # type: ignore[arg-type]
342
+ company_url=company_url,
343
+ job_url=job_url,
344
+ observation=observation,
345
+ expected_salary=expected_salary,
346
+ salary_min=salary_min,
347
+ salary_max=salary_max,
348
+ currency=currency,
349
+ salary_period=salary_period,
350
+ experience_level=experience_level,
351
+ work_mode=work_mode,
352
+ country=country,
353
+ )
354
+ except ValidationError as exc:
355
+ for err in exc.errors():
356
+ field = '.'.join(str(loc) for loc in err['loc'])
357
+ typer.echo(f'Error [{field}]: {err["msg"]}', err=True)
358
+ raise typer.Exit(1)
359
+
360
+ config: AppConfig = ctx.obj
361
+ session = require_session(config.store)
362
+ client = ApiClient(session, config.store)
363
+
364
+ try:
365
+ applications = client.get_json('/applications')
366
+ existing = next(
367
+ (app for app in applications if str(app['id']) == application_id),
368
+ None,
369
+ )
370
+ if existing is None:
371
+ typer.echo('Application not found in the current cycle', err=True)
372
+ raise typer.Exit(1)
373
+ if existing.get('finalized'):
374
+ typer.echo('Finalized applications cannot be edited', err=True)
375
+ raise typer.Exit(1)
376
+
377
+ args = ApplicationArgs(
378
+ company=company,
379
+ company_url=company_url,
380
+ role=role,
381
+ platform=platform,
382
+ mode=str(mode) if mode else None,
383
+ application_date=application_date,
384
+ job_url=job_url,
385
+ observation=observation,
386
+ expected_salary=expected_salary,
387
+ salary_min=salary_min,
388
+ salary_max=salary_max,
389
+ currency=str(currency) if currency else None,
390
+ salary_period=str(salary_period) if salary_period else None,
391
+ experience_level=str(experience_level)
392
+ if experience_level
393
+ else None,
394
+ work_mode=str(work_mode) if work_mode else None,
395
+ country=country,
396
+ clear_job_url=clear_job_url,
397
+ clear_observation=clear_observation,
398
+ clear_country=clear_country,
399
+ clear_salary=clear_salary,
400
+ )
401
+ payload = build_application_payload(client, args, existing=existing)
402
+ updated = client.put_json(f'/applications/{application_id}', payload)
403
+ print_application_summary(updated, 'Updated application')
404
+ finally:
405
+ client.close()
@@ -0,0 +1,73 @@
1
+ from typing import Any
2
+
3
+ from applika.utils.dates import parse_date
4
+
5
+
6
+ def filter_applications(
7
+ applications: list[dict[str, Any]],
8
+ supports: dict[str, Any],
9
+ *,
10
+ search: str | None = None,
11
+ mode: str = 'all',
12
+ status: str = 'all',
13
+ platform: str | None = None,
14
+ from_date: str | None = None,
15
+ to_date: str | None = None,
16
+ ) -> list[dict[str, Any]]:
17
+ platform_id = None
18
+ if platform:
19
+ platform_id = resolve_platform_id(supports, platform)
20
+
21
+ filtered = []
22
+ search_term = (search or '').strip().lower()
23
+ date_from = parse_date(from_date) if from_date else None
24
+ date_to = parse_date(to_date) if to_date else None
25
+
26
+ for application in applications:
27
+ if search_term:
28
+ company_name = (application.get('company_name') or '').lower()
29
+ role = (application.get('role') or '').lower()
30
+ if search_term not in company_name and search_term not in role:
31
+ continue
32
+
33
+ if mode != 'all' and application.get('mode') != mode:
34
+ continue
35
+
36
+ if status == 'active' and application.get('finalized'):
37
+ continue
38
+
39
+ if status == 'finalized' and not application.get('finalized'):
40
+ continue
41
+
42
+ if platform_id and str(application.get('platform_id')) != platform_id:
43
+ continue
44
+
45
+ app_date = parse_date(application['application_date'])
46
+ if date_from and app_date < date_from:
47
+ continue
48
+ if date_to and app_date > date_to:
49
+ continue
50
+
51
+ filtered.append(application)
52
+
53
+ filtered.sort(key=lambda item: item['application_date'], reverse=True)
54
+ return filtered
55
+
56
+
57
+ def resolve_platform_id(supports: dict[str, Any], platform_name: str) -> str:
58
+ normalized_name = platform_name.strip().lower()
59
+ match = next(
60
+ (
61
+ platform
62
+ for platform in supports['platforms']
63
+ if platform['name'].strip().lower() == normalized_name
64
+ ),
65
+ None,
66
+ )
67
+ if match is None:
68
+ valid = ', '.join(
69
+ sorted(platform['name'] for platform in supports['platforms'])
70
+ )
71
+ raise ValueError(f'Unknown platform. Valid options: {valid}')
72
+
73
+ return str(match['id'])