Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce neuro config quota show #1156

Merged
merged 10 commits into from
Nov 8, 2019
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
* [neuro image pull](#neuro-image-pull)
* [neuro image tags](#neuro-image-tags)
* [neuro config](#neuro-config)
* [neuro config quota](#neuro-config-quota)
* [neuro config quota show](#neuro-config-quota-show)
* [neuro config login](#neuro-config-login)
* [neuro config login-with-token](#neuro-config-login-with-token)
* [neuro config login-headless](#neuro-config-login-headless)
Expand Down Expand Up @@ -983,6 +985,13 @@ Name | Description|
|_--help_|Show this message and exit.|


**Command Groups:**

|Usage|Description|
|---|---|
| _[neuro config quota](#neuro-config-quota)_| Quota configuration |


**Commands:**

|Usage|Description|
Expand All @@ -998,6 +1007,51 @@ Name | Description|



### neuro config quota

Quota configuration.

**Usage:**

```bash
neuro config quota [OPTIONS] COMMAND [ARGS]...
```

**Options:**

Name | Description|
|----|------------|
|_--help_|Show this message and exit.|


**Commands:**

|Usage|Description|
|---|---|
| _[neuro config quota show](#neuro-config-quota-show)_| Print quota and remaining computation time |




#### neuro config quota show

Print quota and remaining computation time.

**Usage:**

```bash
neuro config quota show [OPTIONS]
```

**Options:**

Name | Description|
|----|------------|
|_--help_|Show this message and exit.|




### neuro config login

Log into Neuromation Platform.<br/><br/>URL is a platform entrypoint URL.
Expand Down
7 changes: 7 additions & 0 deletions neuromation/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import aiohttp

from neuromation.api.quota import Quota

from .config import _Config
from .core import _Core
from .images import Images
Expand Down Expand Up @@ -46,6 +48,7 @@ def __init__(
self._storage = Storage._create(self._core, self._config)
self._users = Users._create(self._core)
self._parser = Parser._create(self._config, self.username)
self._quota = Quota._create(self._core, self._config)
self._images: Optional[Images] = None

async def close(self) -> None:
Expand Down Expand Up @@ -98,6 +101,10 @@ def images(self) -> Images:
def parse(self) -> Parser:
return self._parser

@property
def quota(self) -> Quota:
return self._quota

def _get_session_cookie(self) -> Optional["Morsel[str]"]:
for cookie in self._core._session.cookie_jar:
if cookie.key == "NEURO_SESSION":
Expand Down
59 changes: 59 additions & 0 deletions neuromation/api/quota.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from dataclasses import dataclass
from typing import Any, Dict, Optional

from yarl import URL

from neuromation.api.config import _Config
from neuromation.api.core import _Core
from neuromation.api.utils import NoPublicConstructor


@dataclass(frozen=True)
class QuotaDetails:
spent_minutes: int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use minutes in API but float seconds.
float("inf") is for infinity, please use this constant instead of None

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use minutes in API but float seconds.

for this, we will need to change the server-side API. Note, currently, we get the time in minutes (see neuro-inc/platform-api#901).

Should we change the API?

Copy link
Contributor

@asvetlov asvetlov Nov 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary, you can multiply by 60 on the client.
In python there are two time representations: float and datetime.
It is confusing enough right now, adding yet another notation makes everything even worse and less usable: people will always convert minutes into something more convenient.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need QuotaDetails? Why not use just float or Optional[timedelta]?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary, you can multiply by 60 on the client.

OK, I will change ..._minutes: int to ..._seconds: float everywhere. But for me personally it'd be confusing if I see GPU spent: 00h 01m 00s, then I run a job for 30 seconds and I still see: GPU spent: 00h 01m 00s. I think, if we need to show time in seconds, the API protocol should be changed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need QuotaDetails? Why not use just float or Optional[timedelta]?

In the first version of my code, there were just fields gpu_spent_minutes, cpu_limit_minutes, etc. But since the lines for CPU and GPU should use the same formatting method _format_quota_details, I found it useful to introduce an additional abstraction QuotaDetails. Though, if it's an eyesore for you, I'm OK to get rid of it. Should I? cc @serhiy-storchaka

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API uses seconds (omit the suffix from field names please).
CLI can stay with minutes, it makes sense.

Like we return file size as bytes in the storage API but show it as gigabytes for large files in CLI.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flat structure with short field names is better IMHO

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API uses seconds (omit the suffix from field names please). CLI can stay with minutes, it makes sense.

Fixed.

Why do we need QuotaDetails? Why not use just float or Optional[timedelta]?

fixed to use a flat structure.

limit_minutes: Optional[int]

@property
def remain_minutes(self) -> Optional[int]:
if self.limit_minutes is None:
# remain: infinity
return None
if self.limit_minutes > self.spent_minutes:
return self.limit_minutes - self.spent_minutes
return 0


@dataclass(frozen=True)
class QuotaInfo:
name: str
gpu_details: QuotaDetails
cpu_details: QuotaDetails


class Quota(metaclass=NoPublicConstructor):
def __init__(self, core: _Core, config: _Config) -> None:
self._core = core
self._config = config

async def get(self) -> QuotaInfo:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How to get quota for another user?
I can do it if have appropriate permissions, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need a funcitonality to see other user's quota? cc @mariyadavydova

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We obviously support it by REST API. Otherwise please modify the server as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, yes, we need a functionality to see other user's quota (for example, I would like to be able to see the quotas left for our free tier users). However, config seems to be a bad place for such functionality, as other config commands refer to your personal information (or your server's). Let's file another issue and think where we should put the quota-related functionality (this refers to other discussions in this PR too). This particular issue should solve the immediate problem - "How do I, free tier user, get to know how much quota I have?".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We obviously support it by REST API. Otherwise please modify the server as well.

Please, don't :) I use this functionality to bump quotas via REST API.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mariyadavydova here is a public API class, not neuro config CLI command group.
API is more generic (and potentially harder to change publishing).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I'm sorry. Then I think that we should update this class accordingly and keep it as is for a while, as this quota functionality is emerging now and we do not have a clear understanding of how it should be organized.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async def get(self, user: Optional[str]=None), the default None is for self.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already done

url = URL(f"stats/users/{self._config.auth_token.username}")
async with self._core.request("GET", url) as resp:
res = await resp.json()
return _quota_info_from_api(res)


def _quota_info_from_api(payload: Dict[str, Any]) -> QuotaInfo:
total_gpu_str = payload["quota"].get("total_gpu_run_time_minutes")
total_cpu_str = payload["quota"].get("total_non_gpu_run_time_minutes")

gpu_details = QuotaDetails(
spent_minutes=int(payload["jobs"]["total_gpu_run_time_minutes"]),
limit_minutes=int(total_gpu_str) if total_gpu_str else None,
)
cpu_details = QuotaDetails(
spent_minutes=int(payload["jobs"]["total_non_gpu_run_time_minutes"]),
limit_minutes=int(total_cpu_str) if total_gpu_str else None,
)
return QuotaInfo(
name=payload["name"], gpu_details=gpu_details, cpu_details=cpu_details
)
20 changes: 20 additions & 0 deletions neuromation/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
login_with_token as api_login_with_token,
logout as api_logout,
)
from neuromation.cli.formatters.config import QuotaInfoFormatter

from .formatters import ConfigFormatter
from .root import Root
Expand All @@ -28,6 +29,11 @@ def config() -> None:
"""Client configuration."""


@group()
def quota() -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use nested group, neuro config show-quota is just fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, the team decided to use neuro config quota show in order to extend quota group with additional funcitonality: neuro config quota add, etc. Perhaps, the good solution would be to make the group quota non-nested: neuro quota show

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided to put quota management into the config group, that's it.
Please don't add redundant nesting.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the common user cannot change quota.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also please keep in mind: later, after organizations implementing, we may find that quota better fits somewhere in the organization management structures. Now we don't add top-level neuro quota but put a simple command into the config group for this reason.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

"""Quota configuration."""


@command()
@async_cmd()
async def show(root: Root) -> None:
Expand All @@ -38,6 +44,17 @@ async def show(root: Root) -> None:
click.echo(fmt(root))


@command("show")
@async_cmd()
async def quota_show(root: Root) -> None:
"""
Print quota and remaining computation time.
"""
quota = await root.client.quota.get()
fmt = QuotaInfoFormatter()
click.echo(fmt(quota))


@command()
@async_cmd()
async def show_token(root: Root) -> None:
Expand Down Expand Up @@ -194,3 +211,6 @@ async def docker(root: Root, docker_config: str) -> None:
config.add_command(docker)

config.add_command(logout)
config.add_command(quota)

quota.add_command(quota_show)
33 changes: 33 additions & 0 deletions neuromation/cli/formatters/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from click import style

from neuromation.api import Preset
from neuromation.api.quota import QuotaDetails, QuotaInfo
from neuromation.cli.root import Root
from neuromation.cli.utils import format_size

Expand Down Expand Up @@ -66,3 +67,35 @@ def _format_presets(self, presets: Dict[str, Preset]) -> Iterator[str]:
yield from table(
rows=rows, aligns=[Align.LEFT, Align.RIGHT, Align.RIGHT, Align.CENTER]
)


class QuotaInfoFormatter:
QUOTA_NOT_SET = "infinity"

def __call__(self, quota: QuotaInfo) -> str:
return (
f"{style('GPU:', bold=True)}"
f" {self._format_quota_details(quota.gpu_details)}"
"\n"
f"{style('CPU:', bold=True)}"
f" {self._format_quota_details(quota.cpu_details)}"
)

def _format_quota_details(self, details: QuotaDetails) -> str:
spent_str = f"spent: {self._format_time(details.spent_minutes)}"
quota_str = "quota: "
if details.limit_minutes is not None:
quota_str += self._format_time(details.limit_minutes)
assert details.remain_minutes is not None
quota_str += f", left: {self._format_time(details.remain_minutes)}"
else:
quota_str += self.QUOTA_NOT_SET
return f"{spent_str} ({quota_str})"

def _format_time(self, minutes_total: int) -> str:
hours = minutes_total // 60
minutes = minutes_total % 60
minutes_zero_padded = "{0:02d}m".format(minutes)
hours_zero_padded = "{0:02d}".format(hours)
hours_space_padded = f"{hours_zero_padded:>2}h"
return f"{hours_space_padded} {minutes_zero_padded}"
69 changes: 69 additions & 0 deletions tests/api/test_quota.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from typing import Callable

from aiohttp import web

from neuromation.api import Client
from neuromation.api.quota import QuotaDetails, QuotaInfo
from tests import _TestServerFactory


_MakeClient = Callable[..., Client]


async def test_quota_get(
aiohttp_server: _TestServerFactory, make_client: _MakeClient
) -> None:
async def handle_stats(request: web.Request) -> web.StreamResponse:
data = {
"name": request.match_info["name"],
"jobs": {
"total_gpu_run_time_minutes": 101,
"total_non_gpu_run_time_minutes": 102,
},
"quota": {
"total_gpu_run_time_minutes": 201,
"total_non_gpu_run_time_minutes": 202,
},
}
return web.json_response(data)

app = web.Application()
app.router.add_get("/stats/users/{name}", handle_stats)

srv = await aiohttp_server(app)

async with make_client(srv.make_url("/")) as client:
quota = await client.quota.get()
assert quota == QuotaInfo(
name=client.username,
gpu_details=QuotaDetails(spent_minutes=101, limit_minutes=201),
cpu_details=QuotaDetails(spent_minutes=102, limit_minutes=202),
)


async def test_quota_get_no_quota(
aiohttp_server: _TestServerFactory, make_client: _MakeClient
) -> None:
async def handle_stats(request: web.Request) -> web.StreamResponse:
data = {
"name": request.match_info["name"],
"jobs": {
"total_gpu_run_time_minutes": 101,
"total_non_gpu_run_time_minutes": 102,
},
"quota": {},
}
return web.json_response(data)

app = web.Application()
app.router.add_get("/stats/users/{name}", handle_stats)

srv = await aiohttp_server(app)

async with make_client(srv.make_url("/")) as client:
quota = await client.quota.get()
assert quota == QuotaInfo(
name=client.username,
gpu_details=QuotaDetails(spent_minutes=101, limit_minutes=None),
cpu_details=QuotaDetails(spent_minutes=102, limit_minutes=None),
)
Loading